**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
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
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
<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>
<router-outlet></router-outlet>
</div>
</div>
+
+<ng-container *ngIf="prependModerationActions">
+ <my-account-report #accountReportModal [account]="account"></my-account-report>
+</ng-container>
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'
styleUrls: [ './accounts.component.scss' ]
})
export class AccountsComponent implements OnInit, OnDestroy {
+ @ViewChild('accountReportModal') accountReportModal: AccountReportComponent
+
account: Account
accountUser: User
videoChannels: VideoChannel[] = []
isAccountManageable = false
accountFollowerTitle = ''
+ prependModerationActions: DropdownAction<any>[]
+
private routeSub: Subscription
constructor (
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 ]))
)
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
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'
})
}
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' })
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 () {
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'
ModerationComponent,
VideoBlockListComponent,
- VideoAbuseListComponent,
- VideoAbuseDetailsComponent,
+
+ AbuseListComponent,
+ AbuseDetailsComponent,
+
ModerationCommentModalComponent,
InstanceServerBlocklistComponent,
InstanceAccountBlocklistComponent,
--- /dev/null
+<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:"' + abuse.reporterAccount.displayName + '"' }"
+ 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:"' + abuse.reporterAccount.displayName + '"' }"
+ 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:"' +abuse.flaggedAccount.displayName + '"' }"
+ 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:"' +abuse.flaggedAccount.displayName + '"' }"
+ 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>
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
}
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]
}))
--- /dev/null
+<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>
@include disable-default-a-behaviour;
}
-.video-abuse-states .glyphicon-comment {
+.abuse-states .glyphicon-comment {
margin-left: 0.5rem;
}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+export * from './abuse-details.component'
+export * from './abuse-list.component'
+export * from './moderation-comment-modal.component'
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',
@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()
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 })
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.'))
+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'
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;
}
.screenratio {
div {
@include miniature-thumbnail;
+
display: inline-flex;
justify-content: center;
align-items: center;
};
}
+.comment-html {
+ background-color: #ececec;
+ padding: 10px;
+}
+
.chip {
@include chip;
}
}
-.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;
color: pvar(--inputPlaceholderColor);
}
- .video-table-video-image-label {
+ .table-video-image-label {
@include static-thumbnail-overlay;
position: absolute;
border-radius: 3px;
}
}
- .video-table-video-text {
+ .table-video-text {
display: inline-flex;
flex-direction: column;
justify-content: center;
}
div + div {
- font-size: 80%;
+ color: var(--greyForegroundColor);
+ font-size: 11px;
}
}
}
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'
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'
}
}
},
+++ /dev/null
-export * from './video-abuse-list.component'
-export * from './moderation-comment-modal.component'
+++ /dev/null
-<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:"' + videoAbuse.reporterAccount.displayName + '"' }" 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:"' + videoAbuse.reporterAccount.displayName + '"' }" 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:"' +videoAbuse.video.channel.ownerAccount.displayName + '"' }" 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:"' +videoAbuse.video.channel.ownerAccount.displayName + '"' }" 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>
+++ /dev/null
-<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>
+++ /dev/null
-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)
- }
-}
</a>
</div>
<div>
- <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' + user?.account.displayName + '"' }">
- <div class="dashboard-num">{{ user.videoAbusesCount }}</div>
+ <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:"' + user?.account.displayName + '"' }">
+ <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:"' + user?.account.displayName + '" state:accepted' }">
- <div class="dashboard-num">{{ user.videoAbusesAcceptedCount }} / {{ user.videoAbusesCreatedCount }}</div>
+ <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:"' + user?.account.displayName + '" state:accepted' }">
+ <div class="dashboard-num">{{ user.abusesAcceptedCount }} / {{ user.abusesCreatedCount }}</div>
<div class="dashboard-label" i18n>Authored reports accepted</div>
</a>
</div>
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)'),
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,
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',
<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>
</div>
</div>
</div>
+
+<ng-container *ngIf="prependModerationActions">
+ <my-comment-report #commentReportModal [comment]="comment"></my-comment-report>
+</ng-container>
-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',
styleUrls: ['./video-comment.component.scss']
})
export class VideoCommentComponent implements OnInit, OnChanges {
+ @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
+
@Input() video: Video
@Input() comment: VideoComment
@Input() parentComments: VideoComment[] = []
@Output() resetReply = new EventEmitter()
@Output() timestampClicked = new EventEmitter<number>()
+ prependModerationActions: DropdownAction<any>[]
+
sanitizedCommentHTML = ''
newParentComments: VideoComment[] = []
commentUser: User
constructor (
+ private i18n: I18n,
private markdownService: MarkdownService,
private authService: AuthService,
private userService: UserService,
} 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()
}
}
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',
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'
SharedVideoPlaylistModule,
SharedUserSubscriptionModule,
SharedModerationModule,
- SharedGlobalIconModule
+ SharedGlobalIconModule,
+ SharedVideoCommentModule
],
declarations: [
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 {
rowsPerPage = this.rowsPerPageOptions[0]
expandedRows = {}
- private searchStream: Subject<string>
+ protected searchStream: Subject<string>
abstract getIdentifier (): string
}
loadLazy (event: LazyLoadEvent) {
+ logger('Load lazy %o.', event)
+
this.sort = {
order: event.sortOrder,
field: event.sortField
)
.subscribe(search => {
this.search = search
+
+ logger('On search %s.', this.search)
+
this.loadData()
})
}
}
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 = {}
}
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
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
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'
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
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.'),
}
}
- 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.'),
+export * from './abuse-validators.service'
export * from './batch-domains-validators.service'
export * from './custom-config-validators.service'
export * from './form-validator.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'
LoginValidatorsService,
ResetPasswordValidatorsService,
UserValidatorsService,
- VideoAbuseValidatorsService,
+ AbuseValidatorsService,
VideoAcceptOwnershipValidatorsService,
VideoBlockValidatorsService,
VideoCaptionsValidatorsService,
LoginValidatorsService,
ResetPasswordValidatorsService,
UserValidatorsService,
- VideoAbuseValidatorsService,
+ AbuseValidatorsService,
VideoAcceptOwnershipValidatorsService,
VideoBlockValidatorsService,
VideoCaptionsValidatorsService,
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) {
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()
}
video: VideoInfo
}
- videoAbuse?: {
+ abuse?: {
id: number
- video: VideoInfo
+
+ video?: VideoInfo
+
+ comment?: {
+ threadId: number
+
+ video: {
+ id: number
+ uuid: string
+ name: string
+ }
+ }
+
+ account?: ActorInfo
}
videoBlacklist?: {
// Additional fields
videoUrl?: string
commentUrl?: any[]
- videoAbuseUrl?: string
+ abuseUrl?: string
videoAutoBlacklistUrl?: string
accountUrl?: string
videoImportIdentifier?: string
this.comment = hash.comment
if (this.comment) this.setAvatarUrl(this.comment.account)
- this.videoAbuse = hash.videoAbuse
+ this.abuse = hash.abuse
this.videoBlacklist = hash.videoBlacklist
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:
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)
}
}
<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>
<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>
<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>
--- /dev/null
+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
+ }
+
+}
+export * from './report-modals'
+
+export * from './abuse.service'
export * from './account-block.model'
export * from './account-blocklist.component'
export * from './batch-domains-modal.component'
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'
--- /dev/null
+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
+ }
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+export * from './account-report.component'
+export * from './comment-report.component'
+export * from './video-report.component'
--- /dev/null
+<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>
<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>
--- /dev/null
+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
+ }
+}
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: [
UserModerationDropdownComponent,
VideoBlockComponent,
VideoReportComponent,
- BatchDomainsModalComponent
+ BatchDomainsModalComponent,
+ CommentReportComponent,
+ AccountReportComponent
],
exports: [
UserModerationDropdownComponent,
VideoBlockComponent,
VideoReportComponent,
- BatchDomainsModalComponent
+ BatchDomainsModalComponent,
+ CommentReportComponent,
+ AccountReportComponent
],
providers: [
BlocklistService,
BulkService,
- VideoAbuseService,
+ AbuseService,
VideoBlockService
]
})
@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'
private buildActions () {
this.userActions = []
+ if (this.prependActions) {
+ this.userActions = [
+ this.prependActions
+ ]
+ }
+
if (this.authService.isLoggedIn()) {
const authUser = this.authService.getUser()
+++ /dev/null
-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))
- )
- }}
+++ /dev/null
-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
- }
-}
--- /dev/null
+export * from './video-comment.service'
+export * from './video-comment.model'
+export * from './video-comment-thread-tree.model'
+
+export * from './shared-video-comment.module'
--- /dev/null
+
+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 { }
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'
--- /dev/null
+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 } })
+}
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'
apiRouter.use(apiRateLimiter)
apiRouter.use('/server', serverRouter)
+apiRouter.use('/abuses', abuseRouter)
apiRouter.use('/bulk', bulkRouter)
apiRouter.use('/oauth-clients', oauthClientsRouter)
apiRouter.use('/config', configRouter)
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()
}
const values: UserNotificationSetting = {
newVideoFromSubscription: body.newVideoFromSubscription,
newCommentOnMyVideo: body.newCommentOnMyVideo,
- videoAbuseAsModerator: body.videoAbuseAsModerator,
+ abuseAsModerator: body.abuseAsModerator,
videoAutoBlacklistAsModerator: body.videoAutoBlacklistAsModerator,
blacklistOnMyVideo: body.blacklistOnMyVideo,
myVideoPublished: body.myVideoPublished,
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,
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,
)
abuseVideoRouter.put('/:videoId/abuse/:id',
authenticate,
- ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES),
+ ensureUserHasRight(UserRight.MANAGE_ABUSES),
asyncMiddleware(videoAbuseUpdateValidator),
asyncRetryTransactionMiddleware(updateVideoAbuse)
)
)
abuseVideoRouter.delete('/:videoId/abuse/:id',
authenticate,
- ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES),
+ ensureUserHasRight(UserRight.MANAGE_ABUSES),
asyncMiddleware(videoAbuseGetValidator),
asyncRetryTransactionMiddleware(deleteVideoAbuse)
)
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,
}
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)
}
-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
}
}
-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)
}
}
CommentAuditView,
UserAuditView,
VideoAuditView,
- VideoAbuseAuditView,
+ AbuseAuditView,
CustomConfigAuditView
}
--- /dev/null
+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
+}
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)
}
+++ /dev/null
-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
-}
-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
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
}
--- /dev/null
+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
+}
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)
}
+export * from './abuses'
export * from './accounts'
-export * from './video-abuses'
export * from './video-blacklists'
export * from './video-captions'
export * from './video-channels'
+++ /dev/null
-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
-}
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'
// ---------------------------------------------------------------------------
-const LAST_MIGRATION_VERSION = 515
+const LAST_MIGRATION_VERSION = 520
// ---------------------------------------------------------------------------
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' ],
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' ],
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
},
[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 = {
VIDEO_RATE_TYPES,
VIDEO_TRANSCODING_FPS,
FFMPEG_NICE,
- VIDEO_ABUSE_STATES,
+ ABUSE_STATES,
VIDEO_CHANNELS,
LRU_CACHE,
JOB_REQUEST_TIMEOUT,
+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
TagModel,
AccountVideoRateModel,
UserModel,
+ AbuseModel,
+ VideoCommentAbuseModel,
VideoAbuseModel,
VideoModel,
VideoChangeOwnershipModel,
import * as Sequelize from 'sequelize'
-import { VideoAbuseState } from '../../../shared/models/videos'
+import { AbuseState } from '../../../shared/models'
async function up (utils: {
transaction: Sequelize.Transaction
}
{
- const query = 'UPDATE "videoAbuse" SET "state" = ' + VideoAbuseState.PENDING
+ const query = 'UPDATE "videoAbuse" SET "state" = ' + AbuseState.PENDING
await utils.sequelize.query(query)
}
--- /dev/null
+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
+}
-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)
}
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
-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 })
}
}
}
-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)
// ---------------------------------------------------------------------------
export {
- sendVideoAbuse
+ sendAbuse
}
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'
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) {
getVideoCacheStreamingPlaylistActivityPubUrl,
getVideoChannelActivityPubUrl,
getAccountActivityPubUrl,
- getVideoAbuseActivityPubUrl,
+ getAbuseActivityPubUrl,
getActorFollowActivityPubUrl,
getActorFollowAcceptActivityPubUrl,
getVideoAnnounceActivityPubUrl,
+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 {
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
}
}
}
--- /dev/null
+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;")
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}]
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;")
--- /dev/null
+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;")
-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
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,
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
}
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'
.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 {
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 })
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,
--- /dev/null
+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
+}
+export * from './abuse'
export * from './account'
export * from './blocklist'
export * from './oembed'
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)
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)
export {
usersSortValidator,
- videoAbusesSortValidator,
+ abusesSortValidator,
videoChannelsSortValidator,
videoImportsSortValidator,
videosSearchSortValidator,
.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')
-export * from './video-abuses'
export * from './video-blacklist'
export * from './video-captions'
export * from './video-channels'
+++ /dev/null
-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
-}
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 = [
// ---------------------------------------------------------------------------
-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
}
if (videoComment.isDeleted()) {
res.status(409)
.json({ error: 'This comment is already deleted' })
- .end()
+
return false
}
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
}
--- /dev/null
+
+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
+}
--- /dev/null
+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)
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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
+}
-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'
}
export type SummaryOptions = {
+ actorRequired?: boolean // Default: true
whereActor?: WhereOptions
withAccountBlockerIds?: number[]
}
}
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,
.findAll(query)
}
+ getClientUrl () {
+ return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
+ }
+
toFormattedJSON (this: MAccountFormattable): Account {
const actor = this.Actor.toFormattedJSON()
const account = {
@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)
return {
newCommentOnMyVideo: this.newCommentOnMyVideo,
newVideoFromSubscription: this.newVideoFromSubscription,
- videoAbuseAsModerator: this.videoAbuseAsModerator,
+ abuseAsModerator: this.abuseAsModerator,
videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator,
blacklistOnMyVideo: this.blacklistOnMyVideo,
myVideoPublished: this.myVideoPublished,
+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'
{
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() ]
+ }
+ ]
},
{
}
},
{
- fields: [ 'videoAbuseId' ],
+ fields: [ 'abuseId' ],
where: {
- videoAbuseId: {
+ abuseId: {
[Op.ne]: null
}
}
})
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
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,
video,
videoImport,
comment,
- videoAbuse,
+ abuse,
videoBlacklist,
account,
actorFollow,
}
}
+ 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
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,
'(' +
`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(
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 = {
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)
+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',
+++ /dev/null
-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)
- }
-}
}
export type SummaryOptions = {
+ actorRequired?: boolean // Default: true
withAccount?: boolean // Default: false
withAccountBlockerIds?: number[]
}
{
attributes: [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ],
model: ActorModel.unscoped(),
- required: true,
+ required: options.actorRequired ?? true,
include: [
{
attributes: [ 'host' ],
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'
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'
})
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: {
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,
attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
}
- order = buildOrder(model, options.sort)
+ order = buildOrder(options.sort)
suffix += `${order} `
}
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)
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'
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'
} 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,
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,
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',
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) {
--- /dev/null
+/* 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 ])
+ })
+})
+import './abuses'
import './accounts'
import './blocklist'
import './bulk'
const correctFields: UserNotificationSetting = {
newVideoFromSubscription: UserNotificationSettingValue.WEB,
newCommentOnMyVideo: UserNotificationSettingValue.WEB,
- videoAbuseAsModerator: UserNotificationSettingValue.WEB,
+ abuseAsModerator: UserNotificationSettingValue.WEB,
videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB,
blacklistOnMyVideo: UserNotificationSettingValue.WEB,
myVideoImportFinished: UserNotificationSettingValue.WEB,
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha'
-
+import { AbuseState, VideoAbuseCreate } from '@shared/models'
import {
cleanupTests,
createUser,
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
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 () {
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 }
})
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)
})
})
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)
// Order of the tests we want to execute
import './activitypub'
import './check-params'
+import './moderation'
import './notifications'
import './redundancy'
import './search'
--- /dev/null
+/* 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)
+ })
+})
--- /dev/null
+export * from './abuses'
+export * from './blocklist'
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,
import {
checkAutoInstanceFollowing,
CheckerBaseParams,
+ checkNewAccountAbuseForModerators,
checkNewBlacklistOnMyVideo,
+ checkNewCommentAbuseForModerators,
checkNewInstanceFollower,
checkNewVideoAbuseForModerators,
checkNewVideoFromSubscription,
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 () {
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')
})
})
/* 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,
createUser,
flushAndRunServer,
removeVideoFromBlacklist,
- reportVideoAbuse,
+ reportAbuse,
resetPassword,
ServerInfo,
setAccessTokensToServers,
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',
}
const res = await uploadVideo(server.url, server.accessToken, attributes)
videoUUID = res.body.video.uuid
+ videoId = res.body.video.id
}
})
})
})
- 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)
-import './users-verification'
-import './blocklist'
import './user-subscriptions'
import './users'
import './users-multiple-servers'
+import './users-verification'
/* 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,
createUser,
deleteMe,
flushAndRunServer,
+ getAbusesList,
getAccountRatings,
getBlacklistedVideosList,
getCustomConfig,
getUserInformation,
getUsersList,
getUsersListPaginationAndSort,
- getVideoAbusesList,
getVideoChannel,
getVideosList,
installPlugin,
registerUserWithChannel,
removeUser,
removeVideo,
- reportVideoAbuse,
+ reportAbuse,
ServerInfo,
testImage,
unblockUser,
+ updateAbuse,
updateCustomSubConfig,
updateMyAvatar,
updateMyUser,
updateUser,
- updateVideoAbuse,
uploadVideo,
userLogin,
waitJobs
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
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)
})
})
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 () {
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
})
})
/* 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'
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)
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)
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
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')
})
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
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")
}
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,
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")
}
})
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)
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)
export * from './account'
+export * from './moderation'
export * from './oauth'
export * from './server'
export * from './user'
--- /dev/null
+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>
--- /dev/null
+export * from './abuse'
-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>
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>
// ############################################################################
export type MUserNotification =
- Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'VideoAbuse' | 'VideoBlacklist' |
+ Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'Abuse' | 'VideoBlacklist' |
'VideoImport' | 'Account' | 'ActorFollow'>
// ############################################################################
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> &
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'
+++ /dev/null
-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'>>
import { RegisterServerAuthExternalOptions } from '@server/types'
import {
+ MAbuse,
MAccountBlocklist,
MActorUrl,
MStreamingPlaylist,
MComment,
MCommentOwnerVideoReply,
MUserDefault,
- MVideoAbuse,
MVideoBlacklist,
MVideoCaptionVideo,
MVideoFullLight,
videoCaption?: MVideoCaptionVideo
- videoAbuse?: MVideoAbuse
+ abuse?: MAbuse
videoStreamingPlaylist?: MStreamingPlaylist
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'
--- /dev/null
+
+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
+}
video?: {
id: number
uuid: string
- name: string
- account: {
+ name?: string
+ account?: {
name: string
}
}
}
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')
}
}
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
})
}
}
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
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,
smtp: {
hostname: 'localhost',
port
+ },
+ signup: {
+ limit: 20
}
}
const servers = await flushAndRunMultipleServers(serversCount, overrideConfig)
markAsReadNotifications,
getLastNotification,
checkNewInstanceFollower,
- prepareNotificationsTest
+ prepareNotificationsTest,
+ checkNewCommentAbuseForModerators,
+ checkNewAccountAbuseForModerators
}
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
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
token: string,
videoId: string | number,
videoAbuseId: number,
- body: VideoAbuseUpdate,
+ body: AbuseUpdate,
statusCodeExpected = 204
) {
const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId
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 |
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 {
import { ActivityFlagReasonObject } from './common-objects'
-export interface VideoAbuseObject {
+export interface AbuseObject {
type: 'Flag'
content: string
object: string | string[]
+
tag?: ActivityFlagReasonObject[]
+
startAt?: number
endAt?: number
}
-import { VideoAbusePredefinedReasonsString } from '@shared/models/videos'
+import { AbusePredefinedReasonsString } from '@shared/models'
export interface ActivityIdentifierObject {
identifier: string
export interface ActivityFlagReasonObject {
type: 'Hashtag'
- name: VideoAbusePredefinedReasonsString
+ name: AbusePredefinedReasonsString
}
export type ActivityTagObject =
+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'
export * from './activitypub'
export * from './actors'
export * from './avatars'
-export * from './blocklist'
+export * from './moderation'
export * from './bulk'
export * from './redundancy'
export * from './users'
export * from './server'
export * from './oauth-client-local.model'
export * from './result-list.model'
-export * from './server/server-config.model'
--- /dev/null
+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
+}
--- /dev/null
+export type AbuseFilter = 'video' | 'comment' | 'account'
--- /dev/null
+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
+}
-export enum VideoAbuseState {
+export enum AbuseState {
PENDING = 1,
REJECTED = 2,
ACCEPTED = 3
--- /dev/null
+import { AbuseState } from './abuse-state.model'
+
+export interface AbuseUpdate {
+ moderationComment?: string
+
+ state?: AbuseState
+}
--- /dev/null
+export type AbuseVideoIs = 'deleted' | 'blacklisted'
--- /dev/null
+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
+}
--- /dev/null
+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'
+export * from './abuse'
export * from './account-block.model'
export * from './server-block.model'
export interface UserNotificationSetting {
newVideoFromSubscription: UserNotificationSettingValue
newCommentOnMyVideo: UserNotificationSettingValue
- videoAbuseAsModerator: UserNotificationSettingValue
+ abuseAsModerator: UserNotificationSettingValue
videoAutoBlacklistAsModerator: UserNotificationSettingValue
blacklistOnMyVideo: UserNotificationSettingValue
myVideoPublished: UserNotificationSettingValue
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,
video: VideoInfo
}
- videoAbuse?: {
+ abuse?: {
id: number
- video: VideoInfo
+
+ video?: VideoInfo
+
+ comment?: {
+ threadId: number
+
+ video: {
+ id: number
+ uuid: string
+ name: string
+ }
+ }
+
+ account?: ActorInfo
}
videoBlacklist?: {
MANAGE_SERVER_REDUNDANCY,
- MANAGE_VIDEO_ABUSES,
+ MANAGE_ABUSES,
MANAGE_JOBS,
[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,
videoQuotaDaily: number
videoQuotaUsed?: number
videoQuotaUsedDaily?: number
+
videosCount?: number
- videoAbusesCount?: number
- videoAbusesAcceptedCount?: number
- videoAbusesCreatedCount?: number
+
+ abusesCount?: number
+ abusesAcceptedCount?: number
+ abusesCreatedCount?: number
+
videoCommentsCount? : number
theme: string
+++ /dev/null
-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'
+++ /dev/null
-import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
-
-export interface VideoAbuseCreate {
- reason: string
- predefinedReasons?: VideoAbusePredefinedReasonsString[]
- startAt?: number
- endAt?: number
-}
+++ /dev/null
-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
-}
+++ /dev/null
-import { VideoAbuseState } from './video-abuse-state.model'
-
-export interface VideoAbuseUpdate {
- moderationComment?: string
- state?: VideoAbuseState
-}
+++ /dev/null
-export type VideoAbuseVideoIs = 'deleted' | 'blacklisted'
+++ /dev/null
-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
-}
-export * from './abuse'
export * from './blacklist'
export * from './caption'
export * from './channel'
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.
- Search
- name: Moderation
tags:
- - Video Abuses
+ - Abuses
- Video Blocks
- Account Blocks
- Server Blocks
$ref: '#/components/schemas/NotificationSettingValue'
newCommentOnMyVideo:
$ref: '#/components/schemas/NotificationSettingValue'
- videoAbuseAsModerator:
+ abuseAsModerator:
$ref: '#/components/schemas/NotificationSettingValue'
videoAutoBlacklistAsModerator:
$ref: '#/components/schemas/NotificationSettingValue'
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
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
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:
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'
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:
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:
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:
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
'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':
name: abuseId
in: path
required: true
- description: Video abuse id
+ description: Abuse id
schema:
type: integer
captionLanguage:
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
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
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:
description: The user daily video quota
videosCount:
type: integer
- videoAbusesCount:
+ abusesCount:
type: integer
- videoAbusesAcceptedCount:
+ abusesAcceptedCount:
type: integer
- videoAbusesCreatedCount:
+ abusesCreatedCount:
type: integer
videoCommentsCount:
type: integer
- `2` NEW_COMMENT_ON_MY_VIDEO
- - `3` NEW_VIDEO_ABUSE_FOR_MODERATORS
+ - `3` NEW_ABUSE_FOR_MODERATORS
- `4` BLACKLIST_ON_MY_VIDEO