From 94148c9028829b5576a5dcbfba2c7fb9cf6443d3 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Mon, 27 Jul 2020 11:40:30 +0200 Subject: [PATCH] Add abuse messages management in my account --- client/src/app/+admin/admin.module.ts | 8 +- .../abuse-list/abuse-list.component.html | 193 +------ .../abuse-list/abuse-list.component.scss | 32 -- .../abuse-list/abuse-list.component.ts | 470 +---------------- .../app/+admin/moderation/abuse-list/index.ts | 2 - .../instance-account-blocklist.component.ts | 2 +- .../+admin/moderation/moderation.component.ts | 2 +- .../video-block-list.component.scss | 9 + .../video-block-list.component.ts | 2 +- .../my-account-abuses-list.component.html | 6 + .../my-account-abuses-list.component.ts | 11 + .../my-account-server-blocklist.component.ts | 2 +- .../+my-account/my-account-routing.module.ts | 10 + .../app/+my-account/my-account.component.ts | 5 + .../src/app/+my-account/my-account.module.ts | 6 +- client/src/app/helpers/utils.ts | 5 +- .../abuse-details.component.html | 16 +- .../abuse-details.component.scss | 34 ++ .../abuse-details.component.ts | 8 +- .../abuse-list-table.component.html | 194 +++++++ .../abuse-list-table.component.scss} | 100 +--- .../abuse-list-table.component.ts | 487 ++++++++++++++++++ .../abuse-message-modal.component.html | 16 +- .../abuse-message-modal.component.scss | 47 +- .../abuse-message-modal.component.ts | 22 +- .../src/app/shared/shared-abuse-list/index.ts | 7 + .../moderation-comment-modal.component.html | 0 .../moderation-comment-modal.component.scss | 0 .../moderation-comment-modal.component.ts | 0 .../processed-abuse.model.ts | 25 + .../shared-abuse-list.module.ts | 42 ++ .../shared/shared-main/account/actor.model.ts | 12 +- .../shared/shared-moderation/abuse.service.ts | 93 ++-- .../src/app/shared/shared-moderation/index.ts | 1 - .../shared/shared-moderation/moderation.scss | 50 ++ .../server-blocklist.component.scss | 13 + .../shared-moderation.module.ts | 7 +- server/controllers/api/abuse.ts | 4 + server/controllers/api/users/my-abuses.ts | 2 +- server/helpers/middlewares/abuses.ts | 2 +- server/middlewares/validators/abuse.ts | 16 + server/models/abuse/abuse.ts | 26 +- server/tests/api/check-params/abuses.ts | 52 +- server/tests/api/moderation/abuses.ts | 19 +- server/types/models/moderation/abuse.ts | 6 +- server/typings/express/index.d.ts | 5 +- shared/models/moderation/abuse/abuse.model.ts | 2 +- 47 files changed, 1184 insertions(+), 889 deletions(-) delete mode 100644 client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss create mode 100644 client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html create mode 100644 client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts rename client/src/app/{+admin/moderation/abuse-list => shared/shared-abuse-list}/abuse-details.component.html (79%) create mode 100644 client/src/app/shared/shared-abuse-list/abuse-details.component.scss rename client/src/app/{+admin/moderation/abuse-list => shared/shared-abuse-list}/abuse-details.component.ts (87%) create mode 100644 client/src/app/shared/shared-abuse-list/abuse-list-table.component.html rename client/src/app/{+admin/moderation/moderation.component.scss => shared/shared-abuse-list/abuse-list-table.component.scss} (56%) create mode 100644 client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts rename client/src/app/shared/{shared-moderation => shared-abuse-list}/abuse-message-modal.component.html (59%) rename client/src/app/shared/{shared-moderation => shared-abuse-list}/abuse-message-modal.component.scss (56%) rename client/src/app/shared/{shared-moderation => shared-abuse-list}/abuse-message-modal.component.ts (84%) create mode 100644 client/src/app/shared/shared-abuse-list/index.ts rename client/src/app/{+admin/moderation/abuse-list => shared/shared-abuse-list}/moderation-comment-modal.component.html (100%) rename client/src/app/{+admin/moderation/abuse-list => shared/shared-abuse-list}/moderation-comment-modal.component.scss (100%) rename client/src/app/{+admin/moderation/abuse-list => shared/shared-abuse-list}/moderation-comment-modal.component.ts (100%) create mode 100644 client/src/app/shared/shared-abuse-list/processed-abuse.model.ts create mode 100644 client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts create mode 100644 client/src/app/shared/shared-moderation/moderation.scss diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts index c59bd2927..da517a55b 100644 --- a/client/src/app/+admin/admin.module.ts +++ b/client/src/app/+admin/admin.module.ts @@ -2,6 +2,7 @@ import { ChartModule } from 'primeng/chart' import { SelectButtonModule } from 'primeng/selectbutton' import { TableModule } from 'primeng/table' import { NgModule } from '@angular/core' +import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' import { SharedFormModule } from '@app/shared/shared-forms' import { SharedGlobalIconModule } from '@app/shared/shared-icons' import { SharedMainModule } from '@app/shared/shared-main' @@ -14,10 +15,9 @@ 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, AbuseListComponent, VideoBlockListComponent } from './moderation' +import { AbuseListComponent, VideoBlockListComponent } from './moderation' import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist' import { ModerationComponent } from './moderation/moderation.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' @@ -36,6 +36,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom SharedFormModule, SharedModerationModule, SharedGlobalIconModule, + SharedAbuseListModule, TableModule, SelectButtonModule, @@ -60,11 +61,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom ModerationComponent, VideoBlockListComponent, - AbuseListComponent, - AbuseDetailsComponent, - ModerationCommentModalComponent, InstanceServerBlocklistComponent, InstanceAccountBlocklistComponent, 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 index 9fae5667f..9a6c124e1 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html +++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.html @@ -3,195 +3,4 @@ Reports - - -
-
-
- - - - Clear filters -
-
-
-
- - - - - Reporter - Video/Comment/Account - Created - State - Messages - - - - - - - - - - - - - - -
- Avatar -
- {{ abuse.reporterAccount.displayName }} - {{ abuse.reporterAccount.nameWithHost }} -
-
-
- - - Deleted account - - - - - - - -
-
- - - {{ abuse.nth }}/{{ abuse.count }} - -
- -
-
- - - {{ abuse.video.name }} -
-
by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }}
-
-
-
- - - -
-
- Deleted -
- -
-
- {{ abuse.video.name }} - -
-
by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }}
-
-
- -
- - - - - - - - - - - - - - - - Account deleted - - - - - - {{ abuse.createdAt | date: 'short' }} - - - - - - - - - {{ abuse.countMessages }} - - - - - - - - -
- - - - - - - - - - - - -
- No abuses found matching current filters. - No abuses found. -
- - -
-
- - - + diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss deleted file mode 100644 index 48536e3c2..000000000 --- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.scss +++ /dev/null @@ -1,32 +0,0 @@ -@import 'mixins'; -@import 'miniature'; - -.video-details-date-updated { - font-size: 90%; - margin-top: .1rem; -} - -.video-details-links { - @include disable-default-a-behaviour; -} - -.abuse-states .glyphicon-comment { - margin-left: 0.5rem; -} - -.input-group { - @include peertube-input-group(300px); - - .dropdown-toggle::after { - margin-left: 0; - } -} - -.abuse-messages { - my-global-icon { - width: 22px; - margin-left: 3px; - position: relative; - top: -2px; - } -} 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 index 86121fe58..85a150de9 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts +++ b/client/src/app/+admin/moderation/abuse-list/abuse-list.component.ts @@ -1,474 +1,10 @@ -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, AbuseMessageModalComponent } from '@app/shared/shared-moderation' -import { VideoCommentService } from '@app/shared/shared-video-comment' -import { I18n } from '@ngx-translate/i18n-polyfill' -import { AdminAbuse, 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 = AdminAbuse & { - 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: AdminAbuse['video'] & { - channel: AdminAbuse['video']['channel'] & { - ownerAccount: Account - } - } -} +import { Component } from '@angular/core' @Component({ selector: 'my-abuse-list', templateUrl: './abuse-list.component.html', - styleUrls: [ '../moderation.component.scss', './abuse-list.component.scss' ] + styleUrls: [ ] }) -export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit { - @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent - @ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent - - abuses: ProcessedAbuse[] = [] - totalRecords = 0 - sort: SortMeta = { field: 'createdAt', order: 1 } - pagination: RestPagination = { count: this.rowsPerPage, start: 0 } - - abuseActions: DropdownAction[][] = [] - - 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: AdminAbuse) { - 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: AdminAbuse) { - return abuse.state.id === AbuseState.ACCEPTED - } - - isAbuseRejected (abuse: AdminAbuse) { - return abuse.state.id === AbuseState.REJECTED - } - - getVideoUrl (abuse: AdminAbuse) { - return Video.buildClientUrl(abuse.video.uuid) - } - - getCommentUrl (abuse: AdminAbuse) { - return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId - } - - getAccountUrl (abuse: ProcessedAbuse) { - return '/accounts/' + abuse.flaggedAccount.nameWithHost - } - - getVideoEmbed (abuse: AdminAbuse) { - 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: AdminAbuse) { - 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: AdminAbuse, state: AbuseState) { - this.abuseService.updateAbuse(abuse, { state }) - .subscribe( - () => this.loadData(), - - err => this.notifier.error(err.message) - ) - } - - onCountMessagesUpdated (event: { abuseId: number, countMessages: number }) { - const abuse = this.abuses.find(a => a.id === event.abuseId) - - if (!abuse) { - console.error('Cannot find abuse %d.', event.abuseId) - return - } - - abuse.countMessages = event.countMessages - } - - openAbuseMessagesModal (abuse: AdminAbuse) { - this.abuseMessagesModal.openModal(abuse) - } - - protected loadData () { - logger('Load data.') - - return this.abuseService.getAdminAbuses({ - 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[] { - return [ - { - label: this.i18n('Internal actions'), - isHeader: true - }, - { - label: this.i18n('Delete report'), - handler: abuse => this.removeAbuse(abuse) - }, - { - label: this.i18n('Messages'), - handler: abuse => this.openAbuseMessagesModal(abuse) - }, - { - label: this.i18n('Add internal 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[] { - 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[] { - 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[] { - 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[] { - 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) - ) - } +export class AbuseListComponent { - 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 index c6037dab4..45cebdf4e 100644 --- a/client/src/app/+admin/moderation/abuse-list/index.ts +++ b/client/src/app/+admin/moderation/abuse-list/index.ts @@ -1,3 +1 @@ -export * from './abuse-details.component' export * from './abuse-list.component' -export * from './moderation-comment-modal.component' diff --git a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts index d9fec29ce..548f3c917 100644 --- a/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts +++ b/client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.ts @@ -3,7 +3,7 @@ import { BlocklistComponentType, GenericAccountBlocklistComponent } from '@app/s @Component({ selector: 'my-instance-account-blocklist', - styleUrls: [ '../moderation.component.scss', '../../../shared/shared-moderation/account-blocklist.component.scss' ], + styleUrls: [ '../../../shared/shared-moderation/moderation.scss', '../../../shared/shared-moderation/account-blocklist.component.scss' ], templateUrl: '../../../shared/shared-moderation/account-blocklist.component.html' }) export class InstanceAccountBlocklistComponent extends GenericAccountBlocklistComponent { diff --git a/client/src/app/+admin/moderation/moderation.component.ts b/client/src/app/+admin/moderation/moderation.component.ts index b0f5eb224..85665ea4f 100644 --- a/client/src/app/+admin/moderation/moderation.component.ts +++ b/client/src/app/+admin/moderation/moderation.component.ts @@ -3,7 +3,7 @@ import { ServerService } from '@app/core' @Component({ templateUrl: './moderation.component.html', - styleUrls: [ './moderation.component.scss' ] + styleUrls: [ ] }) export class ModerationComponent implements OnInit { autoBlockVideosEnabled = false diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.scss b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.scss index 43a365608..c92d1c39c 100644 --- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.scss +++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.scss @@ -16,3 +16,12 @@ my-global-icon { margin-left: 0; } } + +.caption { + justify-content: flex-end; + + input { + @include peertube-input-text(250px); + flex-grow: 1; + } +} diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts index 5a5132527..dfdf65c19 100644 --- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts +++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts @@ -11,7 +11,7 @@ import { VideoBlacklist, VideoBlacklistType } from '@shared/models' @Component({ selector: 'my-video-block-list', templateUrl: './video-block-list.component.html', - styleUrls: [ '../moderation.component.scss', './video-block-list.component.scss' ] + styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-block-list.component.scss' ] }) export class VideoBlockListComponent extends RestTable implements OnInit, AfterViewInit { blocklist: (VideoBlacklist & { reasonHtml?: string })[] = [] diff --git a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html new file mode 100644 index 000000000..59ca61be6 --- /dev/null +++ b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.html @@ -0,0 +1,6 @@ +

+ + Reports +

+ + diff --git a/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts new file mode 100644 index 000000000..e5dd723ff --- /dev/null +++ b/client/src/app/+my-account/my-account-abuses/my-account-abuses-list.component.ts @@ -0,0 +1,11 @@ + +import { Component } from '@angular/core' + +@Component({ + selector: 'my-account-abuses-list', + templateUrl: './my-account-abuses-list.component.html', + styleUrls: [ ] +}) +export class MyAccountAbusesListComponent { + +} diff --git a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts index 9b983a197..50e724f87 100644 --- a/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts +++ b/client/src/app/+my-account/my-account-blocklist/my-account-server-blocklist.component.ts @@ -3,7 +3,7 @@ import { BlocklistComponentType, GenericServerBlocklistComponent } from '@app/sh @Component({ selector: 'my-account-server-blocklist', - styleUrls: [ '../../+admin/moderation/moderation.component.scss', '../../shared/shared-moderation/server-blocklist.component.scss' ], + styleUrls: [ '../../shared/shared-moderation/moderation.scss', '../../shared/shared-moderation/server-blocklist.component.scss' ], templateUrl: '../../shared/shared-moderation/server-blocklist.component.html' }) export class MyAccountServerBlocklistComponent extends GenericServerBlocklistComponent { diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts index ac9cf4cfd..0a4897d07 100644 --- a/client/src/app/+my-account/my-account-routing.module.ts +++ b/client/src/app/+my-account/my-account-routing.module.ts @@ -16,6 +16,7 @@ import { MyAccountVideoPlaylistUpdateComponent } from './my-account-video-playli import { MyAccountVideoPlaylistsComponent } from './my-account-video-playlists/my-account-video-playlists.component' import { MyAccountVideosComponent } from './my-account-videos/my-account-videos.component' import { MyAccountComponent } from './my-account.component' +import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component' const myAccountRoutes: Routes = [ { @@ -162,6 +163,15 @@ const myAccountRoutes: Routes = [ title: 'Notifications' } } + }, + { + path: 'abuses', + component: MyAccountAbusesListComponent, + data: { + meta: { + title: 'My abuses' + } + } } ] } diff --git a/client/src/app/+my-account/my-account.component.ts b/client/src/app/+my-account/my-account.component.ts index 5b2238f5a..dc2c8f39c 100644 --- a/client/src/app/+my-account/my-account.component.ts +++ b/client/src/app/+my-account/my-account.component.ts @@ -94,6 +94,11 @@ export class MyAccountComponent implements OnInit { routerLink: '/my-account/blocklist/servers', iconName: 'peertube-x' }, + { + label: this.i18n('My abuses'), + routerLink: '/my-account/abuses', + iconName: 'flag' + }, { label: this.i18n('Ownership changes'), routerLink: '/my-account/ownership', diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 742a516d5..bf5a4fc8a 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -3,6 +3,7 @@ import { InputSwitchModule } from 'primeng/inputswitch' import { TableModule } from 'primeng/table' import { DragDropModule } from '@angular/cdk/drag-drop' import { NgModule } from '@angular/core' +import { SharedAbuseListModule } from '@app/shared/shared-abuse-list' import { SharedFormModule } from '@app/shared/shared-forms' import { SharedGlobalIconModule } from '@app/shared/shared-icons' import { SharedMainModule } from '@app/shared/shared-main' @@ -11,6 +12,7 @@ import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-setti import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscription/shared-user-subscription.module' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module' +import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component' import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component' import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component' import { MyAccountHistoryComponent } from './my-account-history/my-account-history.component' @@ -50,7 +52,8 @@ import { MyAccountComponent } from './my-account.component' SharedUserSubscriptionModule, SharedVideoPlaylistModule, SharedUserInterfaceSettingsModule, - SharedGlobalIconModule + SharedGlobalIconModule, + SharedAbuseListModule ], declarations: [ @@ -69,6 +72,7 @@ import { MyAccountComponent } from './my-account.component' MyAccountDangerZoneComponent, MyAccountSubscriptionsComponent, MyAccountBlocklistComponent, + MyAccountAbusesListComponent, MyAccountServerBlocklistComponent, MyAccountHistoryComponent, MyAccountNotificationsComponent, diff --git a/client/src/app/helpers/utils.ts b/client/src/app/helpers/utils.ts index 9bf22f62f..8e9f72adb 100644 --- a/client/src/app/helpers/utils.ts +++ b/client/src/app/helpers/utils.ts @@ -36,7 +36,10 @@ function populateAsyncUserVideoChannels (authService: AuthService, channel: { id } function getAbsoluteAPIUrl () { - let absoluteAPIUrl = environment.apiUrl + let absoluteAPIUrl = environment.hmr === true + ? 'http://localhost:9000' + : environment.apiUrl + if (!absoluteAPIUrl) { // The API is on the same domain absoluteAPIUrl = window.location.origin diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html b/client/src/app/shared/shared-abuse-list/abuse-details.component.html similarity index 79% rename from client/src/app/+admin/moderation/abuse-list/abuse-details.component.html rename to client/src/app/shared/shared-abuse-list/abuse-details.component.html index cba9cfb73..431fdf5aa 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.html +++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.html @@ -3,11 +3,11 @@
-
+
Reporter - - {abuse.countReportsForReporter, plural, =1 {1 report} other {{{ abuse.countReportsForReporter }} reports}} @@ -32,7 +32,7 @@
Reportee - - {abuse.countReportsForReportee, plural, =1 {1 report} other {{{ abuse.countReportsForReportee }} reports}} @@ -63,7 +63,7 @@ @@ -71,7 +71,7 @@ -
+
Note
diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.scss b/client/src/app/shared/shared-abuse-list/abuse-details.component.scss new file mode 100644 index 000000000..d83eb974d --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.scss @@ -0,0 +1,34 @@ +@import 'variables'; +@import 'mixins'; +@import 'miniature'; + +.screenratio { + div { + @include miniature-thumbnail; + + display: inline-flex; + justify-content: center; + align-items: center; + color: pvar(--inputPlaceholderColor); + } + + @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { + width: 100% !important; + height: 100% !important; + left: 0; + }; +} + +.comment-html { + background-color: #ececec; + padding: 10px; +} + +.abuse-details-date-updated { + font-size: 90%; + margin-top: .1rem; +} + +.abuse-details-links { + @include disable-default-a-behaviour; +} diff --git a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts b/client/src/app/shared/shared-abuse-list/abuse-details.component.ts similarity index 87% rename from client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts rename to client/src/app/shared/shared-abuse-list/abuse-details.component.ts index fb0f65764..cdd4bf2c8 100644 --- a/client/src/app/+admin/moderation/abuse-list/abuse-details.component.ts +++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.ts @@ -1,17 +1,19 @@ import { Component, Input } from '@angular/core' +import { durationToString } from '@app/helpers' import { Actor } from '@app/shared/shared-main' import { I18n } from '@ngx-translate/i18n-polyfill' import { AbusePredefinedReasonsString } from '@shared/models' -import { ProcessedAbuse } from './abuse-list.component' -import { durationToString } from '@app/helpers' +import { ProcessedAbuse } from './processed-abuse.model' @Component({ selector: 'my-abuse-details', templateUrl: './abuse-details.component.html', - styleUrls: [ '../moderation.component.scss' ] + styleUrls: [ '../shared-moderation/moderation.scss', './abuse-details.component.scss' ] }) export class AbuseDetailsComponent { @Input() abuse: ProcessedAbuse + @Input() isAdminView: boolean + @Input() baseRoute: string private predefinedReasonsTranslations: { [key in AbusePredefinedReasonsString]: string } diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html new file mode 100644 index 000000000..a6f707a47 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.html @@ -0,0 +1,194 @@ + + +
+ + + + + + Reporter + Video/Comment/Account + Created + State + Messages + + + + + + + + + + + + + + +
+ Avatar +
+ {{ abuse.reporterAccount.displayName }} + {{ abuse.reporterAccount.nameWithHost }} +
+
+
+ + + Deleted account + + + + + + + +
+
+ + + {{ abuse.nth }}/{{ abuse.count }} + +
+ +
+
+ + + {{ abuse.video.name }} +
+
by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }}
+
+
+
+ + + +
+
+ Deleted +
+ +
+
+ {{ abuse.video.name }} + +
+
by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }}
+
+
+ +
+ + + + + + + + + + + + + + + + Account deleted + + + + + + {{ abuse.createdAt | date: 'short' }} + + + + + + + + + + {{ abuse.countMessages }} + + + + + + + + + +
+ + + + + + + + + + + + +
+ No abuses found matching current filters. + No abuses found. +
+ + +
+ + + + diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss similarity index 56% rename from client/src/app/+admin/moderation/moderation.component.scss rename to client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss index 65fe94d39..7ed7c9e87 100644 --- a/client/src/app/+admin/moderation/moderation.component.scss +++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.scss @@ -2,93 +2,6 @@ @import 'mixins'; @import 'miniature'; -.form-sub-title { - flex-grow: 0; - margin-right: 30px; -} - -.caption { - justify-content: flex-end; - - input { - @include peertube-input-text(250px); - flex-grow: 1; - } -} - -.moderation-expanded { - font-size: 90%; - - .moderation-expanded-label { - font-weight: $font-semibold; - display: inline-block; - 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; - } - } -} - -.table-states { - & > :not(:first-child) { - margin-left: .4rem; - } -} - -p-calendar { - display: block; - - ::ng-deep { - .ui-widget-content { - min-width: 400px; - } - - input { - @include peertube-input-text(100%); - } - } -} - -.screenratio { - div { - @include miniature-thumbnail; - - display: inline-flex; - justify-content: center; - align-items: center; - color: pvar(--inputPlaceholderColor); - } - - @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { - width: 100% !important; - height: 100% !important; - left: 0; - }; -} - -.comment-html { - background-color: #ececec; - padding: 10px; -} - -.chip { - @include chip; -} - -my-action-dropdown.show { - ::ng-deep .dropdown-root { - display: block !important; - } -} - - .table-video-link { @include disable-outline; @@ -179,3 +92,16 @@ my-action-dropdown.show { } } } + +.abuse-states .glyphicon-comment { + margin-left: 0.5rem; +} + +.abuse-messages { + my-global-icon { + width: 22px; + margin-left: 3px; + position: relative; + top: -2px; + } +} diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts new file mode 100644 index 000000000..1d17c9ec9 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts @@ -0,0 +1,487 @@ +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, Input } 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 { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation' +import { VideoCommentService } from '@app/shared/shared-video-comment' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { AbuseState, AdminAbuse } from '@shared/models' +import { AbuseMessageModalComponent } from './abuse-message-modal.component' +import { ModerationCommentModalComponent } from './moderation-comment-modal.component' +import { ProcessedAbuse } from './processed-abuse.model' + +const logger = debug('peertube:moderation:AbuseListTableComponent') + +@Component({ + selector: 'my-abuse-list-table', + templateUrl: './abuse-list-table.component.html', + styleUrls: [ '../shared-moderation/moderation.scss', './abuse-list-table.component.scss' ] +}) +export class AbuseListTableComponent extends RestTable implements OnInit, AfterViewInit { + @Input() viewType: 'admin' | 'user' + @Input() baseRoute: string + + @ViewChild('abuseMessagesModal', { static: true }) abuseMessagesModal: AbuseMessageModalComponent + @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[][] = [] + + 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() + } + + ngOnInit () { + this.abuseActions = [ + this.buildInternalActions(), + + this.buildFlaggedAccountActions(), + + this.buildCommentActions(), + + this.buildVideoActions(), + + this.buildAccountActions() + ] + + 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) + } + + isAdminView () { + return this.viewType === 'admin' + } + + getIdentifier () { + return 'AbuseListTableComponent' + } + + openModerationCommentModal (abuse: AdminAbuse) { + 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([ this.baseRoute ], { queryParams }) + } + + resetTableFilter () { + this.setTableFilter('') + this.setQueryParams('') + this.resetSearch() + } + /* END Table filter functions */ + + isAbuseAccepted (abuse: AdminAbuse) { + return abuse.state.id === AbuseState.ACCEPTED + } + + isAbuseRejected (abuse: AdminAbuse) { + return abuse.state.id === AbuseState.REJECTED + } + + getVideoUrl (abuse: AdminAbuse) { + return Video.buildClientUrl(abuse.video.uuid) + } + + getCommentUrl (abuse: AdminAbuse) { + return Video.buildClientUrl(abuse.comment.video.uuid) + ';threadId=' + abuse.comment.threadId + } + + getAccountUrl (abuse: ProcessedAbuse) { + return '/accounts/' + abuse.flaggedAccount.nameWithHost + } + + getVideoEmbed (abuse: AdminAbuse) { + 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: AdminAbuse) { + 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: AdminAbuse, state: AbuseState) { + this.abuseService.updateAbuse(abuse, { state }) + .subscribe( + () => this.loadData(), + + err => this.notifier.error(err.message) + ) + } + + onCountMessagesUpdated (event: { abuseId: number, countMessages: number }) { + const abuse = this.abuses.find(a => a.id === event.abuseId) + + if (!abuse) { + console.error('Cannot find abuse %d.', event.abuseId) + return + } + + abuse.countMessages = event.countMessages + } + + openAbuseMessagesModal (abuse: AdminAbuse) { + this.abuseMessagesModal.openModal(abuse) + } + + isLocalAbuse (abuse: AdminAbuse) { + if (this.viewType === 'user') return true + + return Actor.IS_LOCAL(abuse.reporterAccount.host) + } + + protected loadData () { + logger('Loading data.') + + const options = { + pagination: this.pagination, + sort: this.sort, + search: this.search + } + + const observable = this.viewType === 'admin' + ? this.abuseService.getAdminAbuses(options) + : this.abuseService.getUserAbuses(options) + + return observable.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) + + if (abuse.moderationComment) { + 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[] { + return [ + { + label: this.i18n('Internal actions'), + isHeader: true + }, + { + label: this.isAdminView() + ? this.i18n('Messages with reporter') + : this.i18n('Messages with moderators'), + handler: abuse => this.openAbuseMessagesModal(abuse), + isDisplayed: abuse => this.isLocalAbuse(abuse) + }, + { + label: this.i18n('Update note'), + handler: abuse => this.openModerationCommentModal(abuse), + isDisplayed: abuse => this.isAdminView() && !!abuse.moderationComment + }, + { + label: this.i18n('Mark as accepted'), + handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED), + isDisplayed: abuse => this.isAdminView() && !this.isAbuseAccepted(abuse) + }, + { + label: this.i18n('Mark as rejected'), + handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED), + isDisplayed: abuse => this.isAdminView() && !this.isAbuseRejected(abuse) + }, + { + label: this.i18n('Add internal note'), + handler: abuse => this.openModerationCommentModal(abuse), + isDisplayed: abuse => this.isAdminView() && !abuse.moderationComment + }, + { + label: this.i18n('Delete report'), + handler: abuse => this.isAdminView() && this.removeAbuse(abuse) + } + ] + } + + private buildFlaggedAccountActions (): DropdownAction[] { + if (!this.isAdminView()) return [] + + 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[] { + if (!this.isAdminView()) return [] + + 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[] { + if (!this.isAdminView()) return [] + + 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[] { + if (!this.isAdminView()) return [] + + 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/shared/shared-moderation/abuse-message-modal.component.html b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html similarity index 59% rename from client/src/app/shared/shared-moderation/abuse-message-modal.component.html rename to client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html index 67c6a3081..cb965b71d 100644 --- a/client/src/app/shared/shared-moderation/abuse-message-modal.component.html +++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.html @@ -1,6 +1,9 @@ @@ -21,9 +24,16 @@
+
+ No messages for now. +
+
- +
{{ formErrors.message }} @@ -31,7 +41,7 @@
- +
diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.scss b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss similarity index 56% rename from client/src/app/shared/shared-moderation/abuse-message-modal.component.scss rename to client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss index 89d6b88c1..4dd025fc4 100644 --- a/client/src/app/shared/shared-moderation/abuse-message-modal.component.scss +++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.scss @@ -3,6 +3,11 @@ form { margin: 20px 20px 0 0; + + .form-group:first-child { + // Keep place to display error message without modifying the height + min-height: 125px; + } } textarea { @@ -15,35 +20,29 @@ textarea { display: flex; flex-direction: column; overflow-y: scroll; - margin-right: 5px; +} + +.no-messages { + display: flex; + font-size: 15px; + justify-content: center; } .message-block { - margin-bottom: 10px; + margin: 0 5px 10px 0; max-width: 60%; .author { color: var(--greyForegroundColor); font-size: 14px; + padding: 0 0 3px 10px; } .bubble { - color: var(--mainForegroundColor); - background-color: var(--greyBackgroundColor); border-radius: 10px; padding: 5px 10px; - - &.by-me { - color: var(--mainForegroundColor); - background-color: var(--secondaryColor); - } - - &.by-moderator { - color: #fff; - background-color: var(--mainColor); - - align-self: flex-end; - } + color: var(--mainForegroundColor); + background-color: var(--greyBackgroundColor); .content { font-size: 15px; @@ -54,4 +53,20 @@ textarea { color: var(--greyForegroundColor); } } + + &.by-me { + + .bubble { + color: var(--mainBackgroundColor); + background-color: var(--mainColorLighter); + + .date { + color: var(--mainBackgroundColor); + } + } + } + + &.by-moderator { + align-self: flex-end; + } } diff --git a/client/src/app/shared/shared-moderation/abuse-message-modal.component.ts b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts similarity index 84% rename from client/src/app/shared/shared-moderation/abuse-message-modal.component.ts rename to client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts index 5822dfe1d..03f5ad735 100644 --- a/client/src/app/shared/shared-moderation/abuse-message-modal.component.ts +++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts @@ -1,11 +1,11 @@ -import { Component, ElementRef, EventEmitter, Output, ViewChild, OnInit } from '@angular/core' -import { Notifier, AuthService } from '@app/core' -import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms' +import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { AuthService, 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 { AbuseMessage, UserAbuse } from '@shared/models' -import { AbuseService } from './abuse.service' +import { AbuseService } from '../shared-moderation' @Component({ selector: 'my-abuse-message-modal', @@ -16,11 +16,14 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit { @ViewChild('modal', { static: true }) modal: NgbModal @ViewChild('messagesBlock', { static: false }) messagesBlock: ElementRef + @Input() isAdminView: boolean + @Output() countMessagesUpdated = new EventEmitter<{ abuseId: number, countMessages: number }>() abuseMessages: AbuseMessage[] = [] textareaMessage: string sendingMessage = false + noResults = false private openedModal: NgbModalRef private abuse: UserAbuse @@ -29,9 +32,9 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit { protected formValidatorService: FormValidatorService, private abuseValidatorsService: AbuseValidatorsService, private modalService: NgbModal, + private i18n: I18n, private auth: AuthService, private notifier: Notifier, - private i18n: I18n, private abuseService: AbuseService ) { super() @@ -94,11 +97,20 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit { return this.auth.getUser().account.id === abuseMessage.account.id } + getPlaceholderMessage () { + if (this.isAdminView) { + return this.i18n('Add a message to communicate with the reporter') + } + + return this.i18n('Add a message to communicate with the moderation team') + } + private loadMessages () { this.abuseService.listAbuseMessages(this.abuse) .subscribe( res => { this.abuseMessages = res.data + this.noResults = this.abuseMessages.length === 0 setTimeout(() => { if (!this.messagesBlock) return diff --git a/client/src/app/shared/shared-abuse-list/index.ts b/client/src/app/shared/shared-abuse-list/index.ts new file mode 100644 index 000000000..3bdd18201 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/index.ts @@ -0,0 +1,7 @@ +export * from './abuse-message-modal.component' +export * from './abuse-list-table.component' +export * from './abuse-details.component' +export * from './moderation-comment-modal.component' +export * from './processed-abuse.model' + +export * from './shared-abuse-list.module' diff --git a/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html similarity index 100% rename from client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.html rename to client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.html diff --git a/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss similarity index 100% rename from client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.scss rename to client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.scss diff --git a/client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts similarity index 100% rename from client/src/app/+admin/moderation/abuse-list/moderation-comment-modal.component.ts rename to client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts diff --git a/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts b/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts new file mode 100644 index 000000000..fce1a8db3 --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/processed-abuse.model.ts @@ -0,0 +1,25 @@ +import { SafeHtml } from '@angular/platform-browser' +import { AdminAbuse } from '@shared/models' +import { Account } from '@app/shared/shared-main' + +// 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 = AdminAbuse & { + 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: AdminAbuse['video'] & { + channel: AdminAbuse['video']['channel'] & { + ownerAccount: Account + } + } +} diff --git a/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts b/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts new file mode 100644 index 000000000..663cd902b --- /dev/null +++ b/client/src/app/shared/shared-abuse-list/shared-abuse-list.module.ts @@ -0,0 +1,42 @@ + +import { TableModule } from 'primeng/table' +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 { SharedModerationModule } from '../shared-moderation' +import { SharedVideoCommentModule } from '../shared-video-comment' +import { AbuseDetailsComponent } from './abuse-details.component' +import { AbuseListTableComponent } from './abuse-list-table.component' +import { AbuseMessageModalComponent } from './abuse-message-modal.component' +import { ModerationCommentModalComponent } from './moderation-comment-modal.component' + +@NgModule({ + imports: [ + TableModule, + + SharedMainModule, + SharedFormModule, + SharedModerationModule, + SharedGlobalIconModule, + SharedVideoCommentModule + ], + + declarations: [ + AbuseDetailsComponent, + AbuseListTableComponent, + ModerationCommentModalComponent, + AbuseMessageModalComponent + ], + + exports: [ + AbuseDetailsComponent, + AbuseListTableComponent, + ModerationCommentModalComponent, + AbuseMessageModalComponent + ], + + providers: [ + ] +}) +export class SharedAbuseListModule { } diff --git a/client/src/app/shared/shared-main/account/actor.model.ts b/client/src/app/shared/shared-main/account/actor.model.ts index bda88bdee..950e256ff 100644 --- a/client/src/app/shared/shared-main/account/actor.model.ts +++ b/client/src/app/shared/shared-main/account/actor.model.ts @@ -41,6 +41,13 @@ export abstract class Actor implements ActorServer { return accountName + '@' + host } + static IS_LOCAL (host: string) { + const absoluteAPIUrl = getAbsoluteAPIUrl() + const thisHost = new URL(absoluteAPIUrl).host + + return host.trim() === thisHost + } + protected constructor (hash: ActorServer) { this.id = hash.id this.url = hash.url @@ -53,10 +60,7 @@ export abstract class Actor implements ActorServer { 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.isLocal = Actor.IS_LOCAL(this.host) this.updateComputedAttributes() } diff --git a/client/src/app/shared/shared-moderation/abuse.service.ts b/client/src/app/shared/shared-moderation/abuse.service.ts index 652d8370f..c1aa62023 100644 --- a/client/src/app/shared/shared-moderation/abuse.service.ts +++ b/client/src/app/shared/shared-moderation/abuse.service.ts @@ -5,13 +5,24 @@ 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 { AdminAbuse, AbuseCreate, AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, ResultList, UserAbuse, AbuseMessage } from '@shared/models' -import { environment } from '../../../environments/environment' import { I18n } from '@ngx-translate/i18n-polyfill' +import { + AbuseCreate, + AbuseFilter, + AbuseMessage, + AbusePredefinedReasonsString, + AbuseState, + AbuseUpdate, + AdminAbuse, + ResultList, + UserAbuse +} from '@shared/models' +import { environment } from '../../../environments/environment' @Injectable() export class AbuseService { private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses' + private static BASE_MY_ABUSE_URL = environment.apiUrl + '/api/v1/users/me/abuses' constructor ( private i18n: I18n, @@ -32,33 +43,7 @@ export class AbuseService { 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) + params = this.buildParamsFromSearch(search, params) } return this.authHttp.get>(url, { params }) @@ -67,6 +52,27 @@ export class AbuseService { ) } + getUserAbuses (options: { + pagination: RestPagination, + sort: SortMeta, + search?: string + }): Observable> { + const { pagination, sort, search } = options + const url = AbuseService.BASE_MY_ABUSE_URL + + let params = new HttpParams() + params = this.restService.addRestGetParams(params, pagination, sort) + + if (search) { + params = this.buildParamsFromSearch(search, params) + } + + return this.authHttp.get>(url, { params }) + .pipe( + catchError(res => this.restExtractor.handleError(res)) + ) + } + reportVideo (parameters: AbuseCreate) { const url = AbuseService.BASE_ABUSE_URL @@ -180,4 +186,33 @@ export class AbuseService { return reasons } + private buildParamsFromSearch (search: string, params: HttpParams) { + 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:' } + }) + + return this.restService.addObjectParams(params, filters) + } } diff --git a/client/src/app/shared/shared-moderation/index.ts b/client/src/app/shared/shared-moderation/index.ts index c8082d4b3..41c910ffe 100644 --- a/client/src/app/shared/shared-moderation/index.ts +++ b/client/src/app/shared/shared-moderation/index.ts @@ -1,6 +1,5 @@ export * from './report-modals' -export * from './abuse-message-modal.component' export * from './abuse.service' export * from './account-block.model' export * from './account-blocklist.component' diff --git a/client/src/app/shared/shared-moderation/moderation.scss b/client/src/app/shared/shared-moderation/moderation.scss new file mode 100644 index 000000000..260346dc5 --- /dev/null +++ b/client/src/app/shared/shared-moderation/moderation.scss @@ -0,0 +1,50 @@ +@import 'variables'; +@import 'mixins'; +@import 'miniature'; + +.moderation-expanded { + font-size: 90%; + + .moderation-expanded-label { + font-weight: $font-semibold; + display: inline-block; + 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; + } + } +} + +.input-group { + @include peertube-input-group(300px); + + .dropdown-toggle::after { + margin-left: 0; + } +} + +.chip { + @include chip; +} + +.caption { + justify-content: flex-end; + + input { + @include peertube-input-text(250px); + flex-grow: 1; + } +} + +my-action-dropdown.show { + ::ng-deep .dropdown-root { + display: block !important; + } +} diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.scss b/client/src/app/shared/shared-moderation/server-blocklist.component.scss index 9ddb76850..31db4d92b 100644 --- a/client/src/app/shared/shared-moderation/server-blocklist.component.scss +++ b/client/src/app/shared/shared-moderation/server-blocklist.component.scss @@ -32,3 +32,16 @@ a { .block-button { @include create-button; } + +.caption { + justify-content: flex-end; + + input { + @include peertube-input-text(250px); + flex-grow: 1; + } +} + +.chip { + @include chip; +} diff --git a/client/src/app/shared/shared-moderation/shared-moderation.module.ts b/client/src/app/shared/shared-moderation/shared-moderation.module.ts index b5b6daf27..b1b98f8d0 100644 --- a/client/src/app/shared/shared-moderation/shared-moderation.module.ts +++ b/client/src/app/shared/shared-moderation/shared-moderation.module.ts @@ -4,7 +4,6 @@ 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 { AbuseMessageModalComponent } from './abuse-message-modal.component' import { AbuseService } from './abuse.service' import { BatchDomainsModalComponent } from './batch-domains-modal.component' import { BlocklistService } from './blocklist.service' @@ -30,8 +29,7 @@ import { VideoBlockService } from './video-block.service' VideoReportComponent, BatchDomainsModalComponent, CommentReportComponent, - AccountReportComponent, - AbuseMessageModalComponent + AccountReportComponent ], exports: [ @@ -41,8 +39,7 @@ import { VideoBlockService } from './video-block.service' VideoReportComponent, BatchDomainsModalComponent, CommentReportComponent, - AccountReportComponent, - AbuseMessageModalComponent + AccountReportComponent ], providers: [ diff --git a/server/controllers/api/abuse.ts b/server/controllers/api/abuse.ts index 50d068157..72e62fc0b 100644 --- a/server/controllers/api/abuse.ts +++ b/server/controllers/api/abuse.ts @@ -16,6 +16,7 @@ import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, + checkAbuseValidForMessagesValidator, deleteAbuseMessageValidator, ensureUserHasRight, getAbuseValidator, @@ -58,12 +59,14 @@ abuseRouter.delete('/:id', abuseRouter.get('/:id/messages', authenticate, asyncMiddleware(getAbuseValidator), + checkAbuseValidForMessagesValidator, asyncRetryTransactionMiddleware(listAbuseMessages) ) abuseRouter.post('/:id/messages', authenticate, asyncMiddleware(getAbuseValidator), + checkAbuseValidForMessagesValidator, addAbuseMessageValidator, asyncRetryTransactionMiddleware(addAbuseMessage) ) @@ -71,6 +74,7 @@ abuseRouter.post('/:id/messages', abuseRouter.delete('/:id/messages/:messageId', authenticate, asyncMiddleware(getAbuseValidator), + checkAbuseValidForMessagesValidator, asyncMiddleware(deleteAbuseMessageValidator), asyncRetryTransactionMiddleware(deleteAbuseMessage) ) diff --git a/server/controllers/api/users/my-abuses.ts b/server/controllers/api/users/my-abuses.ts index e43fc483e..fcd0ce3fc 100644 --- a/server/controllers/api/users/my-abuses.ts +++ b/server/controllers/api/users/my-abuses.ts @@ -43,6 +43,6 @@ async function listMyAbuses (req: express.Request, res: express.Response) { return res.json({ total: resultList.total, - data: resultList.data.map(d => d.toFormattedAdminJSON()) + data: resultList.data.map(d => d.toFormattedUserJSON()) }) } diff --git a/server/helpers/middlewares/abuses.ts b/server/helpers/middlewares/abuses.ts index be8c8b449..659ad8939 100644 --- a/server/helpers/middlewares/abuses.ts +++ b/server/helpers/middlewares/abuses.ts @@ -26,7 +26,7 @@ async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: stri } async function doesAbuseExist (abuseId: number | string, res: Response) { - const abuse = await AbuseModel.loadById(parseInt(abuseId + '', 10)) + const abuse = await AbuseModel.loadByIdWithReporter(parseInt(abuseId + '', 10)) if (!abuse) { res.status(404) diff --git a/server/middlewares/validators/abuse.ts b/server/middlewares/validators/abuse.ts index cb0bc658a..2a096e0af 100644 --- a/server/middlewares/validators/abuse.ts +++ b/server/middlewares/validators/abuse.ts @@ -201,6 +201,21 @@ const getAbuseValidator = [ } ] +const checkAbuseValidForMessagesValidator = [ + (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking checkAbuseValidForMessagesValidator parameters', { parameters: req.body }) + + const abuse = res.locals.abuse + if (abuse.ReporterAccount.isOwned() === false) { + return res.status(400).json({ + error: 'This abuse was created by a user of your instance.', + }) + } + + return next() + } +] + const addAbuseMessageValidator = [ body('message').custom(isAbuseMessageValid).not().isEmpty().withMessage('Should have a valid abuse message'), @@ -357,6 +372,7 @@ export { abuseReportValidator, abuseGetValidator, addAbuseMessageValidator, + checkAbuseValidForMessagesValidator, abuseUpdateValidator, deleteAbuseMessageValidator, abuseListForUserValidator, diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts index 7002502d5..3353e9e41 100644 --- a/server/models/abuse/abuse.ts +++ b/server/models/abuse/abuse.ts @@ -25,14 +25,14 @@ import { AbusePredefinedReasonsString, AbuseState, AbuseVideoIs, - AdminVideoAbuse, AdminAbuse, + AdminVideoAbuse, AdminVideoCommentAbuse, UserAbuse, UserVideoAbuse } from '@shared/models' import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' -import { MAbuse, MAbuseAdminFormattable, MAbuseAP, MUserAccountId, MAbuseUserFormattable } from '../../types/models' +import { MAbuse, MAbuseAdminFormattable, MAbuseAP, MAbuseReporter, MAbuseUserFormattable, 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' @@ -266,7 +266,7 @@ export class AbuseModel extends Model { VideoAbuse: VideoAbuseModel // FIXME: deprecated in 2.3. Remove these validators - static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird { + static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird { const videoWhere: WhereOptions = {} if (videoId) videoWhere.videoId = videoId @@ -278,6 +278,10 @@ export class AbuseModel extends Model { model: VideoAbuseModel, required: true, where: videoWhere + }, + { + model: AccountModel, + as: 'ReporterAccount' } ], where: { @@ -287,11 +291,17 @@ export class AbuseModel extends Model { return AbuseModel.findOne(query) } - static loadById (id: number): Bluebird { + static loadByIdWithReporter (id: number): Bluebird { const query = { where: { id - } + }, + include: [ + { + model: AccountModel, + as: 'ReporterAccount' + } + ] } return AbuseModel.findOne(query) @@ -466,8 +476,6 @@ export class AbuseModel extends Model { label: AbuseModel.getStateLabel(this.state) }, - moderationComment: this.moderationComment, - countMessages, createdAt: this.createdAt, @@ -500,6 +508,8 @@ export class AbuseModel extends Model { video, comment, + moderationComment: this.moderationComment, + reporterAccount: this.ReporterAccount ? this.ReporterAccount.toFormattedJSON() : null, @@ -519,7 +529,7 @@ export class AbuseModel extends Model { const countMessages = this.get('countMessages') as number const video = this.buildBaseVideoAbuse() - const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse() + const comment = this.buildBaseVideoCommentAbuse() const abuse = this.buildBaseAbuse(countMessages || 0) return Object.assign(abuse, { diff --git a/server/tests/api/check-params/abuses.ts b/server/tests/api/check-params/abuses.ts index 5e1d66c25..0ef8f6cac 100644 --- a/server/tests/api/check-params/abuses.ts +++ b/server/tests/api/check-params/abuses.ts @@ -3,21 +3,26 @@ import 'mocha' import { AbuseCreate, AbuseState } from '@shared/models' import { + addAbuseMessage, cleanupTests, createUser, deleteAbuse, + deleteAbuseMessage, + doubleFollow, flushAndRunServer, + generateUserAccessToken, + getAdminAbusesList, + getVideoIdFromUUID, + listAbuseMessages, makeGetRequest, makePostBodyRequest, + reportAbuse, ServerInfo, setAccessTokensToServers, updateAbuse, uploadVideo, userLogin, - generateUserAccessToken, - addAbuseMessage, - listAbuseMessages, - deleteAbuseMessage + waitJobs } from '../../../../shared/extra-utils' import { checkBadCountPagination, @@ -29,6 +34,7 @@ describe('Test abuses API validators', function () { const basePath = '/api/v1/abuses/' let server: ServerInfo + let userAccessToken = '' let userAccessToken2 = '' let abuseId: number @@ -321,7 +327,7 @@ describe('Test abuses API validators', function () { }) }) - describe('When listing abuse message', function () { + describe('When listing abuse messages', function () { it('Should fail with an invalid abuse id', async function () { await listAbuseMessages(server.url, userAccessToken, 888, 404) @@ -382,7 +388,43 @@ describe('Test abuses API validators', function () { }) }) + describe('When trying to manage messages of a remote abuse', function () { + let remoteAbuseId: number + let anotherServer: ServerInfo + + before(async function () { + this.timeout(20000) + + anotherServer = await flushAndRunServer(2) + await setAccessTokensToServers([ anotherServer ]) + + await doubleFollow(anotherServer, server) + + const server2VideoId = await getVideoIdFromUUID(anotherServer.url, server.video.uuid) + await reportAbuse({ + url: anotherServer.url, + token: anotherServer.accessToken, + reason: 'remote server', + videoId: server2VideoId + }) + + await waitJobs([ server, anotherServer ]) + + const res = await getAdminAbusesList({ url: server.url, token: server.accessToken, sort: '-createdAt' }) + remoteAbuseId = res.body.data[0].id + }) + + it('Should fail when listing abuse messages of a remote abuse', async function () { + await listAbuseMessages(server.url, server.accessToken, remoteAbuseId, 400) + }) + + it('Should fail when creating abuse message of a remote abuse', async function () { + await addAbuseMessage(server.url, server.accessToken, remoteAbuseId, 'message', 400) + }) + }) + after(async function () { await cleanupTests([ server ]) }) }) + diff --git a/server/tests/api/moderation/abuses.ts b/server/tests/api/moderation/abuses.ts index 601125fdf..fb765e7e3 100644 --- a/server/tests/api/moderation/abuses.ts +++ b/server/tests/api/moderation/abuses.ts @@ -2,12 +2,23 @@ import 'mocha' import * as chai from 'chai' -import { AbuseFilter, AbusePredefinedReasonsString, AbuseState, Account, AdminAbuse, UserAbuse, VideoComment, AbuseMessage } from '@shared/models' import { + AbuseFilter, + AbuseMessage, + AbusePredefinedReasonsString, + AbuseState, + Account, + AdminAbuse, + UserAbuse, + VideoComment +} from '@shared/models' +import { + addAbuseMessage, addVideoCommentThread, cleanupTests, createUser, deleteAbuse, + deleteAbuseMessage, deleteVideoComment, flushAndRunMultipleServers, generateUserAccessToken, @@ -18,6 +29,7 @@ import { getVideoIdFromUUID, getVideosList, immutableAssign, + listAbuseMessages, removeUser, removeVideo, reportAbuse, @@ -26,10 +38,7 @@ import { updateAbuse, uploadVideo, uploadVideoAndGetId, - userLogin, - addAbuseMessage, - listAbuseMessages, - deleteAbuseMessage + userLogin } from '../../../../shared/extra-utils/index' import { doubleFollow } from '../../../../shared/extra-utils/server/follows' import { waitJobs } from '../../../../shared/extra-utils/server/jobs' diff --git a/server/types/models/moderation/abuse.ts b/server/types/models/moderation/abuse.ts index 39ef50771..d793a720f 100644 --- a/server/types/models/moderation/abuse.ts +++ b/server/types/models/moderation/abuse.ts @@ -2,7 +2,7 @@ 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 { MAccountDefault, MAccountFormattable, MAccountLight, MAccountUrl, MAccount } from '../account' import { MCommentOwner, MCommentUrl, MVideoUrl, MCommentOwnerVideo, MComment, MCommentVideo } from '../video' import { MVideo, MVideoAccountLightBlacklistAllFiles } from '../video/video' @@ -18,6 +18,10 @@ export type MVideoAbuse = Omit export type MCommentAbuse = Omit +export type MAbuseReporter = + MAbuse & + Use<'ReporterAccount', MAccountDefault> + // ############################################################################ export type MVideoAbuseVideo = diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts index 452c6e1a0..d95b8925d 100644 --- a/server/typings/express/index.d.ts +++ b/server/typings/express/index.d.ts @@ -9,7 +9,8 @@ import { MVideoFile, MVideoImmutable, MVideoPlaylistFull, - MVideoPlaylistFullSummary + MVideoPlaylistFullSummary, + MAbuseReporter } from '@server/types/models' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' import { MPlugin, MServer, MServerBlocklist } from '@server/types/models/server' @@ -78,7 +79,7 @@ declare module 'express' { videoCaption?: MVideoCaptionVideo - abuse?: MAbuse + abuse?: MAbuseReporter abuseMessage?: MAbuseMessage videoStreamingPlaylist?: MStreamingPlaylist diff --git a/shared/models/moderation/abuse/abuse.model.ts b/shared/models/moderation/abuse/abuse.model.ts index 7f126ba4a..781870b1a 100644 --- a/shared/models/moderation/abuse/abuse.model.ts +++ b/shared/models/moderation/abuse/abuse.model.ts @@ -79,4 +79,4 @@ export type UserVideoAbuse = Omit export type UserVideoCommentAbuse = AdminVideoCommentAbuse export type UserAbuse = Omit +| 'count' | 'nth' | 'moderationComment'> -- 2.41.0