Use 3 tables to represent abuses

This commit is contained in:
Chocobozzz 2020-07-01 16:05:30 +02:00 committed by Chocobozzz
parent 72493e44e9
commit d95d155988
103 changed files with 2142 additions and 1167 deletions

View File

@ -91,7 +91,7 @@ export class AdminComponent implements OnInit {
} }
hasVideoAbusesRight () { hasVideoAbusesRight () {
return this.auth.getUser().hasRight(UserRight.MANAGE_VIDEO_ABUSES) return this.auth.getUser().hasRight(UserRight.MANAGE_ABUSES)
} }
hasVideoBlocklistRight () { hasVideoBlocklistRight () {

View File

@ -14,10 +14,10 @@ import { FollowersListComponent, FollowsComponent, VideoRedundanciesListComponen
import { FollowingListComponent } from './follows/following-list/following-list.component' import { FollowingListComponent } from './follows/following-list/following-list.component'
import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component' import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.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 { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
import { ModerationComponent } from './moderation/moderation.component' 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 { PluginListInstalledComponent } from './plugins/plugin-list-installed/plugin-list-installed.component'
import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component' import { PluginSearchComponent } from './plugins/plugin-search/plugin-search.component'
import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component' import { PluginShowInstalledComponent } from './plugins/plugin-show-installed/plugin-show-installed.component'
@ -60,8 +60,10 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
ModerationComponent, ModerationComponent,
VideoBlockListComponent, VideoBlockListComponent,
VideoAbuseListComponent,
VideoAbuseDetailsComponent, AbuseListComponent,
AbuseDetailsComponent,
ModerationCommentModalComponent, ModerationCommentModalComponent,
InstanceServerBlocklistComponent, InstanceServerBlocklistComponent,
InstanceAccountBlocklistComponent, InstanceAccountBlocklistComponent,

View File

@ -0,0 +1,93 @@
<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/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="chip">
<img
class="avatar"
[src]="abuse.reporterAccount.avatar?.path"
(error)="switchToDefaultAvatar($event)"
alt="Avatar"
>
<div>
<span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
</div>
</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-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">
<span class="col-3 moderation-expanded-label" i18n>Reportee</span>
<span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip">
<img
class="avatar"
[src]="abuse.video.channel.ownerAccount?.avatar?.path"
(error)="switchToDefaultAvatar($event)"
alt="Avatar"
>
<div>
<span class="text-muted">{{ abuse.video.channel.ownerAccount ? abuse.video.channel.ownerAccount.nameWithHost : '' }}</span>
</div>
</a>
<a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-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 video-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 [routerLink]="[ '/admin/moderation/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="abuse.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.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" [innerHTML]="abuse.moderationCommentHtml"></span>
</div>
</div>
<!-- report right part (video details) -->
<div class="col-4">
<div class="screenratio">
<div *ngIf="abuse.video.deleted || abuse.video.blacklisted">
<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>
</div>

View File

@ -1,19 +1,19 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { Actor } from '@app/shared/shared-main' import { Actor } from '@app/shared/shared-main'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model' import { AbusePredefinedReasonsString } from '@shared/models'
import { ProcessedVideoAbuse } from './video-abuse-list.component' import { ProcessedAbuse } from './abuse-list.component'
import { durationToString } from '@app/helpers' import { durationToString } from '@app/helpers'
@Component({ @Component({
selector: 'my-video-abuse-details', selector: 'my-abuse-details',
templateUrl: './video-abuse-details.component.html', templateUrl: './abuse-details.component.html',
styleUrls: [ '../moderation.component.scss' ] styleUrls: [ '../moderation.component.scss' ]
}) })
export class VideoAbuseDetailsComponent { export class AbuseDetailsComponent {
@Input() videoAbuse: ProcessedVideoAbuse @Input() abuse: ProcessedAbuse
private predefinedReasonsTranslations: { [key in VideoAbusePredefinedReasonsString]: string } private predefinedReasonsTranslations: { [key in AbusePredefinedReasonsString]: string }
constructor ( constructor (
private i18n: I18n private i18n: I18n
@ -31,16 +31,16 @@ export class VideoAbuseDetailsComponent {
} }
get startAt () { get startAt () {
return durationToString(this.videoAbuse.startAt) return durationToString(this.abuse.startAt)
} }
get endAt () { get endAt () {
return durationToString(this.videoAbuse.endAt) return durationToString(this.abuse.endAt)
} }
getPredefinedReasons () { getPredefinedReasons () {
if (!this.videoAbuse.predefinedReasons) return [] if (!this.abuse.predefinedReasons) return []
return this.videoAbuse.predefinedReasons.map(r => ({ return this.abuse.predefinedReasons.map(r => ({
id: r, id: r,
label: this.predefinedReasonsTranslations[r] label: this.predefinedReasonsTranslations[r]
})) }))

View File

@ -1,5 +1,5 @@
<p-table <p-table
[value]="videoAbuses" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [rowsPerPageOptions]="rowsPerPageOptions" [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" [sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id" [resizableColumns]="true"
[showCurrentPageReport]="true" i18n-currentPageReportTemplate [showCurrentPageReport]="true" i18n-currentPageReportTemplate
currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports" currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} reports"
@ -16,11 +16,11 @@
<div role="menu" ngbDropdownMenu> <div role="menu" ngbDropdownMenu>
<h6 class="dropdown-header" i18n>Advanced report filters</h6> <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/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/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/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/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> <a [routerLink]="[ '/admin/moderation/abuses/list' ]" [queryParams]="{ 'search': 'videoIs:deleted' }" class="dropdown-item" i18n>Reports with deleted videos</a>
</div> </div>
</div> </div>
<input <input
@ -45,91 +45,91 @@
</tr> </tr>
</ng-template> </ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-videoAbuse> <ng-template pTemplate="body" let-expanded="expanded" let-abuse>
<tr> <tr>
<td class="c-hand" [pRowToggler]="videoAbuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body"> <td class="c-hand" [pRowToggler]="abuse" i18n-ngbTooltip ngbTooltip="More information" placement="top-left" container="body">
<span class="expander"> <span class="expander">
<i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i> <i [ngClass]="expanded ? 'glyphicon glyphicon-menu-down' : 'glyphicon glyphicon-menu-right'"></i>
</span> </span>
</td> </td>
<td> <td>
<a [href]="videoAbuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer"> <a [href]="abuse.reporterAccount.url" i18n-title title="Open account in a new tab" target="_blank" rel="noopener noreferrer">
<div class="chip two-lines"> <div class="chip two-lines">
<img <img
class="avatar" class="avatar"
[src]="videoAbuse.reporterAccount.avatar?.path" [src]="abuse.reporterAccount.avatar?.path"
(error)="switchToDefaultAvatar($event)" (error)="switchToDefaultAvatar($event)"
alt="Avatar" alt="Avatar"
> >
<div> <div>
{{ videoAbuse.reporterAccount.displayName }} {{ abuse.reporterAccount.displayName }}
<span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span> <span class="text-muted">{{ abuse.reporterAccount.nameWithHost }}</span>
</div> </div>
</div> </div>
</a> </a>
</td> </td>
<td *ngIf="!videoAbuse.video.deleted"> <td *ngIf="!abuse.video.deleted">
<a [href]="getVideoUrl(videoAbuse)" class="video-table-video-link" [title]="videoAbuse.video.name" target="_blank" rel="noopener noreferrer"> <a [href]="getVideoUrl(abuse)" class="video-table-video-link" [title]="abuse.video.name" target="_blank" rel="noopener noreferrer">
<div class="video-table-video"> <div class="video-table-video">
<div class="video-table-video-image"> <div class="video-table-video-image">
<img [src]="videoAbuse.video.thumbnailPath"> <img [src]="abuse.video.thumbnailPath">
<span <span
class="video-table-video-image-label" *ngIf="videoAbuse.count > 1" class="video-table-video-image-label" *ngIf="abuse.count > 1"
i18n-title title="This video has been reported multiple times." i18n-title title="This video has been reported multiple times."
> >
{{ videoAbuse.nth }}/{{ videoAbuse.count }} {{ abuse.nth }}/{{ abuse.count }}
</span> </span>
</div> </div>
<div class="video-table-video-text"> <div class="video-table-video-text">
<div> <div>
<span *ngIf="!videoAbuse.video.blacklisted" class="glyphicon glyphicon-new-window"></span> <span *ngIf="!abuse.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> <span *ngIf="abuse.video.blacklisted" i18n-title title="The video was blocked" class="glyphicon glyphicon-ban-circle"></span>
{{ videoAbuse.video.name }} {{ abuse.video.name }}
</div> </div>
<div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div> <div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div> </div>
</div> </div>
</a> </a>
</td> </td>
<td *ngIf="videoAbuse.video.deleted" class="c-hand" [pRowToggler]="videoAbuse"> <td *ngIf="abuse.video.deleted" class="c-hand" [pRowToggler]="abuse">
<div class="video-table-video" i18n-title title="Video was deleted"> <div class="video-table-video" i18n-title title="Video was deleted">
<div class="video-table-video-image"> <div class="video-table-video-image">
<span i18n>Deleted</span> <span i18n>Deleted</span>
</div> </div>
<div class="video-table-video-text"> <div class="video-table-video-text">
<div> <div>
{{ videoAbuse.video.name }} {{ abuse.video.name }}
<span class="glyphicon glyphicon-trash"></span> <span class="glyphicon glyphicon-trash"></span>
</div> </div>
<div class="text-muted" i18n>by {{ videoAbuse.video.channel?.displayName }} on {{ videoAbuse.video.channel?.host }} </div> <div class="text-muted" i18n>by {{ abuse.video.channel?.displayName }} on {{ abuse.video.channel?.host }} </div>
</div> </div>
</div> </div>
</td> </td>
<td class="c-hand" [pRowToggler]="videoAbuse">{{ videoAbuse.createdAt | date: 'short' }}</td> <td class="c-hand" [pRowToggler]="abuse">{{ abuse.createdAt | date: 'short' }}</td>
<td class="c-hand video-abuse-states" [pRowToggler]="videoAbuse"> <td class="c-hand video-abuse-states" [pRowToggler]="abuse">
<span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span> <span *ngIf="isAbuseAccepted(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-ok"></span>
<span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span> <span *ngIf="isAbuseRejected(abuse)" [title]="abuse.state.label" class="glyphicon glyphicon-remove"></span>
<span *ngIf="videoAbuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="videoAbuse.moderationComment" class="glyphicon glyphicon-comment"></span> <span *ngIf="abuse.moderationComment" container="body" placement="left auto" [ngbTooltip]="abuse.moderationComment" class="glyphicon glyphicon-comment"></span>
</td> </td>
<td class="action-cell"> <td class="action-cell">
<my-action-dropdown <my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body" [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="videoAbuseActions" [entry]="videoAbuse" i18n-label label="Actions" [actions]="abuseActions" [entry]="abuse"
></my-action-dropdown> ></my-action-dropdown>
</td> </td>
</tr> </tr>
</ng-template> </ng-template>
<ng-template pTemplate="rowexpansion" let-videoAbuse> <ng-template pTemplate="rowexpansion" let-abuse>
<tr> <tr>
<td class="expand-cell" colspan="6"> <td class="expand-cell" colspan="6">
<my-video-abuse-details [videoAbuse]="videoAbuse"></my-video-abuse-details> <my-abuse-details [abuse]="abuse"></my-abuse-details>
</td> </td>
</tr> </tr>
</ng-template> </ng-template>

View File

@ -1,5 +1,4 @@
import { SortMeta } from 'primeng/api' import { SortMeta } from 'primeng/api'
import { filter } from 'rxjs/operators'
import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core' import { AfterViewInit, Component, OnInit, ViewChild } from '@angular/core'
@ -7,43 +6,45 @@ import { DomSanitizer } from '@angular/platform-browser'
import { ActivatedRoute, Params, Router } from '@angular/router' import { ActivatedRoute, Params, Router } from '@angular/router'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core' import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable } from '@app/core'
import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main' import { Account, Actor, DropdownAction, Video, VideoService } from '@app/shared/shared-main'
import { BlocklistService, VideoAbuseService, VideoBlockService } from '@app/shared/shared-moderation' import { AbuseService, BlocklistService, VideoBlockService } from '@app/shared/shared-moderation'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoAbuse, VideoAbuseState } from '@shared/models' import { Abuse, AbuseState } from '@shared/models'
import { ModerationCommentModalComponent } from './moderation-comment-modal.component' import { ModerationCommentModalComponent } from './moderation-comment-modal.component'
export type ProcessedVideoAbuse = VideoAbuse & { export type ProcessedAbuse = Abuse & {
moderationCommentHtml?: string, moderationCommentHtml?: string,
reasonHtml?: string reasonHtml?: string
embedHtml?: string embedHtml?: string
updatedAt?: Date updatedAt?: Date
// override bare server-side definitions with rich client-side definitions // override bare server-side definitions with rich client-side definitions
reporterAccount: Account reporterAccount: Account
video: VideoAbuse['video'] & {
channel: VideoAbuse['video']['channel'] & { video: Abuse['video'] & {
channel: Abuse['video']['channel'] & {
ownerAccount: Account ownerAccount: Account
} }
} }
} }
@Component({ @Component({
selector: 'my-video-abuse-list', selector: 'my-abuse-list',
templateUrl: './video-abuse-list.component.html', templateUrl: './abuse-list.component.html',
styleUrls: [ '../moderation.component.scss', './video-abuse-list.component.scss' ] styleUrls: [ '../moderation.component.scss', './abuse-list.component.scss' ]
}) })
export class VideoAbuseListComponent extends RestTable implements OnInit, AfterViewInit { export class AbuseListComponent extends RestTable implements OnInit, AfterViewInit {
@ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent @ViewChild('moderationCommentModal', { static: true }) moderationCommentModal: ModerationCommentModalComponent
videoAbuses: ProcessedVideoAbuse[] = [] abuses: ProcessedAbuse[] = []
totalRecords = 0 totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: 1 } sort: SortMeta = { field: 'createdAt', order: 1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 } pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
videoAbuseActions: DropdownAction<VideoAbuse>[][] = [] abuseActions: DropdownAction<Abuse>[][] = []
constructor ( constructor (
private notifier: Notifier, private notifier: Notifier,
private videoAbuseService: VideoAbuseService, private abuseService: AbuseService,
private blocklistService: BlocklistService, private blocklistService: BlocklistService,
private videoService: VideoService, private videoService: VideoService,
private videoBlocklistService: VideoBlockService, private videoBlocklistService: VideoBlockService,
@ -56,7 +57,7 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
) { ) {
super() super()
this.videoAbuseActions = [ this.abuseActions = [
[ [
{ {
label: this.i18n('Internal actions'), label: this.i18n('Internal actions'),
@ -64,45 +65,45 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
}, },
{ {
label: this.i18n('Delete report'), label: this.i18n('Delete report'),
handler: videoAbuse => this.removeVideoAbuse(videoAbuse) handler: abuse => this.removeAbuse(abuse)
}, },
{ {
label: this.i18n('Add note'), label: this.i18n('Add note'),
handler: videoAbuse => this.openModerationCommentModal(videoAbuse), handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: videoAbuse => !videoAbuse.moderationComment isDisplayed: abuse => !abuse.moderationComment
}, },
{ {
label: this.i18n('Update note'), label: this.i18n('Update note'),
handler: videoAbuse => this.openModerationCommentModal(videoAbuse), handler: abuse => this.openModerationCommentModal(abuse),
isDisplayed: videoAbuse => !!videoAbuse.moderationComment isDisplayed: abuse => !!abuse.moderationComment
}, },
{ {
label: this.i18n('Mark as accepted'), label: this.i18n('Mark as accepted'),
handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED), handler: abuse => this.updateAbuseState(abuse, AbuseState.ACCEPTED),
isDisplayed: videoAbuse => !this.isVideoAbuseAccepted(videoAbuse) isDisplayed: abuse => !this.isAbuseAccepted(abuse)
}, },
{ {
label: this.i18n('Mark as rejected'), label: this.i18n('Mark as rejected'),
handler: videoAbuse => this.updateVideoAbuseState(videoAbuse, VideoAbuseState.REJECTED), handler: abuse => this.updateAbuseState(abuse, AbuseState.REJECTED),
isDisplayed: videoAbuse => !this.isVideoAbuseRejected(videoAbuse) isDisplayed: abuse => !this.isAbuseRejected(abuse)
} }
], ],
[ [
{ {
label: this.i18n('Actions for the video'), label: this.i18n('Actions for the video'),
isHeader: true, isHeader: true,
isDisplayed: videoAbuse => !videoAbuse.video.deleted isDisplayed: abuse => !abuse.video.deleted
}, },
{ {
label: this.i18n('Block video'), label: this.i18n('Block video'),
isDisplayed: videoAbuse => !videoAbuse.video.deleted && !videoAbuse.video.blacklisted, isDisplayed: abuse => !abuse.video.deleted && !abuse.video.blacklisted,
handler: videoAbuse => { handler: abuse => {
this.videoBlocklistService.blockVideo(videoAbuse.video.id, undefined, true) this.videoBlocklistService.blockVideo(abuse.video.id, undefined, true)
.subscribe( .subscribe(
() => { () => {
this.notifier.success(this.i18n('Video blocked.')) this.notifier.success(this.i18n('Video blocked.'))
this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED) this.updateAbuseState(abuse, AbuseState.ACCEPTED)
}, },
err => this.notifier.error(err.message) err => this.notifier.error(err.message)
@ -111,14 +112,14 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
}, },
{ {
label: this.i18n('Unblock video'), label: this.i18n('Unblock video'),
isDisplayed: videoAbuse => !videoAbuse.video.deleted && videoAbuse.video.blacklisted, isDisplayed: abuse => !abuse.video.deleted && abuse.video.blacklisted,
handler: videoAbuse => { handler: abuse => {
this.videoBlocklistService.unblockVideo(videoAbuse.video.id) this.videoBlocklistService.unblockVideo(abuse.video.id)
.subscribe( .subscribe(
() => { () => {
this.notifier.success(this.i18n('Video unblocked.')) this.notifier.success(this.i18n('Video unblocked.'))
this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED) this.updateAbuseState(abuse, AbuseState.ACCEPTED)
}, },
err => this.notifier.error(err.message) err => this.notifier.error(err.message)
@ -127,20 +128,20 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
}, },
{ {
label: this.i18n('Delete video'), label: this.i18n('Delete video'),
isDisplayed: videoAbuse => !videoAbuse.video.deleted, isDisplayed: abuse => !abuse.video.deleted,
handler: async videoAbuse => { handler: async abuse => {
const res = await this.confirmService.confirm( const res = await this.confirmService.confirm(
this.i18n('Do you really want to delete this video?'), this.i18n('Do you really want to delete this video?'),
this.i18n('Delete') this.i18n('Delete')
) )
if (res === false) return if (res === false) return
this.videoService.removeVideo(videoAbuse.video.id) this.videoService.removeVideo(abuse.video.id)
.subscribe( .subscribe(
() => { () => {
this.notifier.success(this.i18n('Video deleted.')) this.notifier.success(this.i18n('Video deleted.'))
this.updateVideoAbuseState(videoAbuse, VideoAbuseState.ACCEPTED) this.updateAbuseState(abuse, AbuseState.ACCEPTED)
}, },
err => this.notifier.error(err.message) err => this.notifier.error(err.message)
@ -155,8 +156,8 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
}, },
{ {
label: this.i18n('Mute reporter'), label: this.i18n('Mute reporter'),
handler: async videoAbuse => { handler: async abuse => {
const account = videoAbuse.reporterAccount as Account const account = abuse.reporterAccount as Account
this.blocklistService.blockAccountByInstance(account) this.blocklistService.blockAccountByInstance(account)
.subscribe( .subscribe(
@ -174,13 +175,13 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
}, },
{ {
label: this.i18n('Mute server'), label: this.i18n('Mute server'),
isDisplayed: videoAbuse => !videoAbuse.reporterAccount.userId, isDisplayed: abuse => !abuse.reporterAccount.userId,
handler: async videoAbuse => { handler: async abuse => {
this.blocklistService.blockServerByInstance(videoAbuse.reporterAccount.host) this.blocklistService.blockServerByInstance(abuse.reporterAccount.host)
.subscribe( .subscribe(
() => { () => {
this.notifier.success( this.notifier.success(
this.i18n('Server {{host}} muted by the instance.', { host: videoAbuse.reporterAccount.host }) this.i18n('Server {{host}} muted by the instance.', { host: abuse.reporterAccount.host })
) )
}, },
@ -209,11 +210,11 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
} }
getIdentifier () { getIdentifier () {
return 'VideoAbuseListComponent' return 'AbuseListComponent'
} }
openModerationCommentModal (videoAbuse: VideoAbuse) { openModerationCommentModal (abuse: Abuse) {
this.moderationCommentModal.openModal(videoAbuse) this.moderationCommentModal.openModal(abuse)
} }
onModerationCommentUpdated () { onModerationCommentUpdated () {
@ -240,26 +241,26 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
} }
/* END Table filter functions */ /* END Table filter functions */
isVideoAbuseAccepted (videoAbuse: VideoAbuse) { isAbuseAccepted (abuse: Abuse) {
return videoAbuse.state.id === VideoAbuseState.ACCEPTED return abuse.state.id === AbuseState.ACCEPTED
} }
isVideoAbuseRejected (videoAbuse: VideoAbuse) { isAbuseRejected (abuse: Abuse) {
return videoAbuse.state.id === VideoAbuseState.REJECTED return abuse.state.id === AbuseState.REJECTED
} }
getVideoUrl (videoAbuse: VideoAbuse) { getVideoUrl (abuse: Abuse) {
return Video.buildClientUrl(videoAbuse.video.uuid) return Video.buildClientUrl(abuse.video.uuid)
} }
getVideoEmbed (videoAbuse: VideoAbuse) { getVideoEmbed (abuse: Abuse) {
return buildVideoEmbed( return buildVideoEmbed(
buildVideoLink({ buildVideoLink({
baseUrl: `${environment.embedUrl}/videos/embed/${videoAbuse.video.uuid}`, baseUrl: `${environment.embedUrl}/videos/embed/${abuse.video.uuid}`,
title: false, title: false,
warningTitle: false, warningTitle: false,
startTime: videoAbuse.startAt, startTime: abuse.startAt,
stopTime: videoAbuse.endAt stopTime: abuse.endAt
}) })
) )
} }
@ -268,11 +269,11 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
} }
async removeVideoAbuse (videoAbuse: VideoAbuse) { async removeAbuse (abuse: Abuse) {
const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete')) const res = await this.confirmService.confirm(this.i18n('Do you really want to delete this abuse report?'), this.i18n('Delete'))
if (res === false) return if (res === false) return
this.videoAbuseService.removeVideoAbuse(videoAbuse).subscribe( this.abuseService.removeAbuse(abuse).subscribe(
() => { () => {
this.notifier.success(this.i18n('Abuse deleted.')) this.notifier.success(this.i18n('Abuse deleted.'))
this.loadData() this.loadData()
@ -282,8 +283,8 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
) )
} }
updateVideoAbuseState (videoAbuse: VideoAbuse, state: VideoAbuseState) { updateAbuseState (abuse: Abuse, state: AbuseState) {
this.videoAbuseService.updateVideoAbuse(videoAbuse, { state }) this.abuseService.updateAbuse(abuse, { state })
.subscribe( .subscribe(
() => this.loadData(), () => this.loadData(),
@ -292,14 +293,14 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
} }
protected loadData () { protected loadData () {
return this.videoAbuseService.getVideoAbuses({ return this.abuseService.getAbuses({
pagination: this.pagination, pagination: this.pagination,
sort: this.sort, sort: this.sort,
search: this.search search: this.search
}).subscribe( }).subscribe(
async resultList => { async resultList => {
this.totalRecords = resultList.total this.totalRecords = resultList.total
const videoAbuses = [] const abuses = []
for (const abuse of resultList.data) { for (const abuse of resultList.data) {
Object.assign(abuse, { Object.assign(abuse, {
@ -312,10 +313,10 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV
if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount) if (abuse.video.channel?.ownerAccount) abuse.video.channel.ownerAccount = new Account(abuse.video.channel.ownerAccount)
if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt if (abuse.updatedAt === abuse.createdAt) delete abuse.updatedAt
videoAbuses.push(abuse as ProcessedVideoAbuse) abuses.push(abuse as ProcessedAbuse)
} }
this.videoAbuses = videoAbuses this.abuses = abuses
}, },
err => this.notifier.error(err.message) err => this.notifier.error(err.message)

View File

@ -0,0 +1,3 @@
export * from './abuse-details.component'
export * from './abuse-list.component'
export * from './moderation-comment-modal.component'

View File

@ -1,11 +1,11 @@
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Notifier } from '@app/core' import { Notifier } from '@app/core'
import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms' import { FormReactive, FormValidatorService, AbuseValidatorsService } from '@app/shared/shared-forms'
import { VideoAbuseService } from '@app/shared/shared-moderation' import { AbuseService } from '@app/shared/shared-moderation'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { VideoAbuse } from '@shared/models' import { Abuse } from '@shared/models'
@Component({ @Component({
selector: 'my-moderation-comment-modal', selector: 'my-moderation-comment-modal',
@ -16,15 +16,15 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
@ViewChild('modal', { static: true }) modal: NgbModal @ViewChild('modal', { static: true }) modal: NgbModal
@Output() commentUpdated = new EventEmitter<string>() @Output() commentUpdated = new EventEmitter<string>()
private abuseToComment: VideoAbuse private abuseToComment: Abuse
private openedModal: NgbModalRef private openedModal: NgbModalRef
constructor ( constructor (
protected formValidatorService: FormValidatorService, protected formValidatorService: FormValidatorService,
private modalService: NgbModal, private modalService: NgbModal,
private notifier: Notifier, private notifier: Notifier,
private videoAbuseService: VideoAbuseService, private abuseService: AbuseService,
private videoAbuseValidatorsService: VideoAbuseValidatorsService, private abuseValidatorsService: AbuseValidatorsService,
private i18n: I18n private i18n: I18n
) { ) {
super() super()
@ -32,11 +32,11 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
ngOnInit () { ngOnInit () {
this.buildForm({ 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.abuseToComment = abuseToComment
this.openedModal = this.modalService.open(this.modal, { centered: true }) this.openedModal = this.modalService.open(this.modal, { centered: true })
@ -54,7 +54,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
async banUser () { async banUser () {
const moderationComment: string = this.form.value[ 'moderationComment' ] const moderationComment: string = this.form.value[ 'moderationComment' ]
this.videoAbuseService.updateVideoAbuse(this.abuseToComment, { moderationComment }) this.abuseService.updateAbuse(this.abuseToComment, { moderationComment })
.subscribe( .subscribe(
() => { () => {
this.notifier.success(this.i18n('Comment updated.')) this.notifier.success(this.i18n('Comment updated.'))

View File

@ -1,5 +1,5 @@
export * from './abuse-list'
export * from './instance-blocklist' export * from './instance-blocklist'
export * from './video-abuse-list'
export * from './video-block-list' export * from './video-block-list'
export * from './moderation.component' export * from './moderation.component'
export * from './moderation.routes' export * from './moderation.routes'

View File

@ -1,7 +1,7 @@
import { Routes } from '@angular/router' import { Routes } from '@angular/router'
import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist' import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from '@app/+admin/moderation/instance-blocklist'
import { ModerationComponent } from '@app/+admin/moderation/moderation.component' 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 { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
import { UserRightGuard } from '@app/core' import { UserRightGuard } from '@app/core'
import { UserRight } from '@shared/models' import { UserRight } from '@shared/models'
@ -13,20 +13,25 @@ export const ModerationRoutes: Routes = [
children: [ children: [
{ {
path: '', path: '',
redirectTo: 'video-abuses/list', redirectTo: 'abuses/list',
pathMatch: 'full' pathMatch: 'full'
}, },
{ {
path: 'video-abuses', path: 'video-abuses',
redirectTo: 'video-abuses/list', redirectTo: 'abuses/list',
pathMatch: 'full' pathMatch: 'full'
}, },
{ {
path: 'video-abuses/list', path: 'video-abuses/list',
component: VideoAbuseListComponent, redirectTo: 'abuses/list',
pathMatch: 'full'
},
{
path: 'abuses/list',
component: AbuseListComponent,
canActivate: [ UserRightGuard ], canActivate: [ UserRightGuard ],
data: { data: {
userRight: UserRight.MANAGE_VIDEO_ABUSES, userRight: UserRight.MANAGE_ABUSES,
meta: { meta: {
title: 'Video reports' title: 'Video reports'
} }

View File

@ -1,2 +0,0 @@
export * from './video-abuse-list.component'
export * from './moderation-comment-modal.component'

View File

@ -1,93 +0,0 @@
<div class="d-flex moderation-expanded">
<!-- report left part (report details) -->
<div class="col-8">
<!-- report metadata -->
<div class="d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reporter</span>
<span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="chip">
<img
class="avatar"
[src]="videoAbuse.reporterAccount.avatar?.path"
(error)="switchToDefaultAvatar($event)"
alt="Avatar"
>
<div>
<span class="text-muted">{{ videoAbuse.reporterAccount.nameWithHost }}</span>
</div>
</a>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reporter:&quot;' + videoAbuse.reporterAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
{videoAbuse.countReportsForReporter, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReporter }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a>
</span>
</div>
<div class="d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reportee</span>
<span class="col-9 moderation-expanded-text">
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="chip">
<img
class="avatar"
[src]="videoAbuse.video.channel.ownerAccount?.avatar?.path"
(error)="switchToDefaultAvatar($event)"
alt="Avatar"
>
<div>
<span class="text-muted">{{ videoAbuse.video.channel.ownerAccount ? videoAbuse.video.channel.ownerAccount.nameWithHost : '' }}</span>
</div>
</a>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'reportee:&quot;' +videoAbuse.video.channel.ownerAccount.displayName + '&quot;' }" class="ml-auto text-muted video-details-links" i18n>
{videoAbuse.countReportsForReportee, plural, =1 {1 report} other {{{ videoAbuse.countReportsForReportee }} reports}}<span class="ml-1 glyphicon glyphicon-flag"></span>
</a>
</span>
</div>
<div class="d-flex" *ngIf="videoAbuse.updatedAt">
<span class="col-3 moderation-expanded-label" i18n>Updated</span>
<time class="col-9 moderation-expanded-text video-details-date-updated">{{ videoAbuse.updatedAt | date: 'medium' }}</time>
</div>
<!-- report text -->
<div class="mt-3 d-flex">
<span class="col-3 moderation-expanded-label">
<ng-container i18n>Report</ng-container>
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': '#' + videoAbuse.id }" class="ml-1 text-muted">#{{ videoAbuse.id }}</a>
</span>
<span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span>
</div>
<div *ngIf="getPredefinedReasons()" class="mt-2 d-flex">
<span class="col-3"></span>
<span class="col-9">
<a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()">
<div>{{ reason.label }}</div>
</a>
</span>
</div>
<div *ngIf="videoAbuse.startAt" class="mt-2 d-flex">
<span class="col-3 moderation-expanded-label" i18n>Reported part</span>
<span class="col-9">
{{ startAt }}<ng-container *ngIf="videoAbuse.endAt"> - {{ endAt }}</ng-container>
</span>
</div>
<div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment">
<span class="col-3 moderation-expanded-label" i18n>Note</span>
<span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span>
</div>
</div>
<!-- report right part (video details) -->
<div class="col-4">
<div class="screenratio">
<div *ngIf="videoAbuse.video.deleted || videoAbuse.video.blacklisted">
<span i18n *ngIf="videoAbuse.video.deleted">The video was deleted</span>
<span i18n *ngIf="!videoAbuse.video.deleted">The video was blocked</span>
</div>
<div *ngIf="!videoAbuse.video.deleted && !videoAbuse.video.blacklisted" [innerHTML]="videoAbuse.embedHtml"></div>
</div>
</div>
</div>

View File

@ -47,7 +47,7 @@ export class MyAccountNotificationPreferencesComponent implements OnInit {
this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[] this.notificationSettingKeys = Object.keys(this.labelNotifications) as (keyof UserNotificationSetting)[]
this.rightNotifications = { this.rightNotifications = {
videoAbuseAsModerator: UserRight.MANAGE_VIDEO_ABUSES, videoAbuseAsModerator: UserRight.MANAGE_ABUSES,
videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST, videoAutoBlacklistAsModerator: UserRight.MANAGE_VIDEO_BLACKLIST,
newUserRegistration: UserRight.MANAGE_USERS, newUserRegistration: UserRight.MANAGE_USERS,
newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW, newInstanceFollower: UserRight.MANAGE_SERVER_FOLLOW,

View File

@ -28,7 +28,7 @@ export class MenuComponent implements OnInit {
private routesPerRight: { [ role in UserRight ]?: string } = { private routesPerRight: { [ role in UserRight ]?: string } = {
[UserRight.MANAGE_USERS]: '/admin/users', [UserRight.MANAGE_USERS]: '/admin/users',
[UserRight.MANAGE_SERVER_FOLLOW]: '/admin/friends', [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_VIDEO_BLACKLIST]: '/admin/moderation/video-blocks',
[UserRight.MANAGE_JOBS]: '/admin/jobs', [UserRight.MANAGE_JOBS]: '/admin/jobs',
[UserRight.MANAGE_CONFIGURATION]: '/admin/config' [UserRight.MANAGE_CONFIGURATION]: '/admin/config'
@ -126,7 +126,7 @@ export class MenuComponent implements OnInit {
const adminRights = [ const adminRights = [
UserRight.MANAGE_USERS, UserRight.MANAGE_USERS,
UserRight.MANAGE_SERVER_FOLLOW, UserRight.MANAGE_SERVER_FOLLOW,
UserRight.MANAGE_VIDEO_ABUSES, UserRight.MANAGE_ABUSES,
UserRight.MANAGE_VIDEO_BLACKLIST, UserRight.MANAGE_VIDEO_BLACKLIST,
UserRight.MANAGE_JOBS, UserRight.MANAGE_JOBS,
UserRight.MANAGE_CONFIGURATION UserRight.MANAGE_CONFIGURATION

View File

@ -4,12 +4,12 @@ import { Injectable } from '@angular/core'
import { BuildFormValidator } from './form-validator.service' import { BuildFormValidator } from './form-validator.service'
@Injectable() @Injectable()
export class VideoAbuseValidatorsService { export class AbuseValidatorsService {
readonly VIDEO_ABUSE_REASON: BuildFormValidator readonly ABUSE_REASON: BuildFormValidator
readonly VIDEO_ABUSE_MODERATION_COMMENT: BuildFormValidator readonly ABUSE_MODERATION_COMMENT: BuildFormValidator
constructor (private i18n: I18n) { constructor (private i18n: I18n) {
this.VIDEO_ABUSE_REASON = { this.ABUSE_REASON = {
VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
MESSAGES: { MESSAGES: {
'required': this.i18n('Report reason is required.'), 'required': this.i18n('Report reason is required.'),
@ -18,7 +18,7 @@ export class VideoAbuseValidatorsService {
} }
} }
this.VIDEO_ABUSE_MODERATION_COMMENT = { this.ABUSE_MODERATION_COMMENT = {
VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ], VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
MESSAGES: { MESSAGES: {
'required': this.i18n('Moderation comment is required.'), 'required': this.i18n('Moderation comment is required.'),

View File

@ -1,3 +1,4 @@
export * from './abuse-validators.service'
export * from './batch-domains-validators.service' export * from './batch-domains-validators.service'
export * from './custom-config-validators.service' export * from './custom-config-validators.service'
export * from './form-validator.service' export * from './form-validator.service'
@ -6,7 +7,6 @@ export * from './instance-validators.service'
export * from './login-validators.service' export * from './login-validators.service'
export * from './reset-password-validators.service' export * from './reset-password-validators.service'
export * from './user-validators.service' export * from './user-validators.service'
export * from './video-abuse-validators.service'
export * from './video-accept-ownership-validators.service' export * from './video-accept-ownership-validators.service'
export * from './video-block-validators.service' export * from './video-block-validators.service'
export * from './video-captions-validators.service' export * from './video-captions-validators.service'

View File

@ -11,7 +11,7 @@ import {
LoginValidatorsService, LoginValidatorsService,
ResetPasswordValidatorsService, ResetPasswordValidatorsService,
UserValidatorsService, UserValidatorsService,
VideoAbuseValidatorsService, AbuseValidatorsService,
VideoAcceptOwnershipValidatorsService, VideoAcceptOwnershipValidatorsService,
VideoBlockValidatorsService, VideoBlockValidatorsService,
VideoCaptionsValidatorsService, VideoCaptionsValidatorsService,
@ -69,7 +69,7 @@ import { TimestampInputComponent } from './timestamp-input.component'
LoginValidatorsService, LoginValidatorsService,
ResetPasswordValidatorsService, ResetPasswordValidatorsService,
UserValidatorsService, UserValidatorsService,
VideoAbuseValidatorsService, AbuseValidatorsService,
VideoAcceptOwnershipValidatorsService, VideoAcceptOwnershipValidatorsService,
VideoBlockValidatorsService, VideoBlockValidatorsService,
VideoCaptionsValidatorsService, VideoCaptionsValidatorsService,

View File

@ -14,7 +14,7 @@ export abstract class Actor implements ActorServer {
avatarUrl: string avatarUrl: string
static GET_ACTOR_AVATAR_URL (actor: { avatar?: Avatar }) { static GET_ACTOR_AVATAR_URL (actor: { avatar?: { url?: string, path: string } }) {
if (actor?.avatar?.url) return actor.avatar.url if (actor?.avatar?.url) return actor.avatar.url
if (actor && actor.avatar) { if (actor && actor.avatar) {

View File

@ -25,9 +25,20 @@ export class UserNotification implements UserNotificationServer {
video: VideoInfo video: VideoInfo
} }
videoAbuse?: { abuse?: {
id: number id: number
video: VideoInfo
video?: VideoInfo
comment?: {
threadId: number
video: {
uuid: string
}
}
account?: ActorInfo
} }
videoBlacklist?: { videoBlacklist?: {
@ -55,7 +66,7 @@ export class UserNotification implements UserNotificationServer {
// Additional fields // Additional fields
videoUrl?: string videoUrl?: string
commentUrl?: any[] commentUrl?: any[]
videoAbuseUrl?: string abuseUrl?: string
videoAutoBlacklistUrl?: string videoAutoBlacklistUrl?: string
accountUrl?: string accountUrl?: string
videoImportIdentifier?: string videoImportIdentifier?: string
@ -78,7 +89,7 @@ export class UserNotification implements UserNotificationServer {
this.comment = hash.comment this.comment = hash.comment
if (this.comment) this.setAvatarUrl(this.comment.account) if (this.comment) this.setAvatarUrl(this.comment.account)
this.videoAbuse = hash.videoAbuse this.abuse = hash.abuse
this.videoBlacklist = hash.videoBlacklist this.videoBlacklist = hash.videoBlacklist
@ -108,8 +119,9 @@ export class UserNotification implements UserNotificationServer {
break break
case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS: case UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS:
this.videoAbuseUrl = '/admin/moderation/video-abuses/list' this.abuseUrl = '/admin/moderation/abuses/list'
this.videoUrl = this.buildVideoUrl(this.videoAbuse.video)
if (this.abuse.video) this.videoUrl = this.buildVideoUrl(this.abuse.video)
break break
case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS: case UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS:
@ -178,7 +190,7 @@ export class UserNotification implements UserNotificationServer {
return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName return videoImport.targetUrl || videoImport.magnetUri || videoImport.torrentName
} }
private setAvatarUrl (actor: { avatarUrl?: string, avatar?: Avatar }) { private setAvatarUrl (actor: { avatarUrl?: string, avatar?: { url?: string, path: string } }) {
actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor) actor.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(actor)
} }
} }

View File

@ -19,7 +19,7 @@
<ng-template #noVideo> <ng-template #noVideo>
<my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
<div class="message" i18n> <div class="message" i18n>
The notification concerns a video now unavailable The notification concerns a video now unavailable
</div> </div>
@ -46,7 +46,7 @@
<my-global-icon iconName="flag" aria-hidden="true"></my-global-icon> <my-global-icon iconName="flag" aria-hidden="true"></my-global-icon>
<div class="message" i18n> <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> <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>
</ng-container> </ng-container>
@ -65,7 +65,7 @@
<a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl"> <a (click)="markAsRead(notification)" [routerLink]="notification.accountUrl">
<img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" /> <img alt="" aria-labelledby="avatar" class="avatar" [src]="notification.comment.account.avatarUrl" />
</a> </a>
<div class="message" i18n> <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> <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> </div>
@ -73,7 +73,7 @@
<ng-template #noComment> <ng-template #noComment>
<my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
<div class="message" i18n> <div class="message" i18n>
The notification concerns a comment now unavailable The notification concerns a comment now unavailable
</div> </div>

View File

@ -5,12 +5,12 @@ import { catchError, map } from 'rxjs/operators'
import { HttpClient, HttpParams } from '@angular/common/http' import { HttpClient, HttpParams } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { RestExtractor, RestPagination, RestService } from '@app/core' import { RestExtractor, RestPagination, RestService } from '@app/core'
import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '@shared/models' import { AbuseUpdate, ResultList, Abuse, AbuseCreate, AbuseState } from '@shared/models'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
@Injectable() @Injectable()
export class VideoAbuseService { export class AbuseService {
private static BASE_VIDEO_ABUSE_URL = environment.apiUrl + '/api/v1/videos/' private static BASE_ABUSE_URL = environment.apiUrl + '/api/v1/abuses'
constructor ( constructor (
private authHttp: HttpClient, private authHttp: HttpClient,
@ -18,13 +18,13 @@ export class VideoAbuseService {
private restExtractor: RestExtractor private restExtractor: RestExtractor
) {} ) {}
getVideoAbuses (options: { getAbuses (options: {
pagination: RestPagination, pagination: RestPagination,
sort: SortMeta, sort: SortMeta,
search?: string search?: string
}): Observable<ResultList<VideoAbuse>> { }): Observable<ResultList<Abuse>> {
const { pagination, sort, search } = options const { pagination, sort, search } = options
const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + 'abuse' const url = AbuseService.BASE_ABUSE_URL + 'abuse'
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
@ -35,9 +35,9 @@ export class VideoAbuseService {
state: { state: {
prefix: 'state:', prefix: 'state:',
handler: v => { handler: v => {
if (v === 'accepted') return VideoAbuseState.ACCEPTED if (v === 'accepted') return AbuseState.ACCEPTED
if (v === 'pending') return VideoAbuseState.PENDING if (v === 'pending') return AbuseState.PENDING
if (v === 'rejected') return VideoAbuseState.REJECTED if (v === 'rejected') return AbuseState.REJECTED
return undefined return undefined
} }
@ -59,14 +59,14 @@ export class VideoAbuseService {
params = this.restService.addObjectParams(params, filters) params = this.restService.addObjectParams(params, filters)
} }
return this.authHttp.get<ResultList<VideoAbuse>>(url, { params }) return this.authHttp.get<ResultList<Abuse>>(url, { params })
.pipe( .pipe(
catchError(res => this.restExtractor.handleError(res)) catchError(res => this.restExtractor.handleError(res))
) )
} }
reportVideo (parameters: { id: number } & VideoAbuseCreate) { reportVideo (parameters: AbuseCreate) {
const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse' const url = AbuseService.BASE_ABUSE_URL
const body = omit(parameters, [ 'id' ]) const body = omit(parameters, [ 'id' ])
@ -77,8 +77,8 @@ export class VideoAbuseService {
) )
} }
updateVideoAbuse (videoAbuse: VideoAbuse, abuseUpdate: VideoAbuseUpdate) { updateAbuse (abuse: Abuse, abuseUpdate: AbuseUpdate) {
const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.put(url, abuseUpdate) return this.authHttp.put(url, abuseUpdate)
.pipe( .pipe(
@ -87,8 +87,8 @@ export class VideoAbuseService {
) )
} }
removeVideoAbuse (videoAbuse: VideoAbuse) { removeAbuse (abuse: Abuse) {
const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + videoAbuse.video.uuid + '/abuse/' + videoAbuse.id const url = AbuseService.BASE_ABUSE_URL + '/' + abuse.id
return this.authHttp.delete(url) return this.authHttp.delete(url)
.pipe( .pipe(

View File

@ -1,3 +1,4 @@
export * from './abuse.service'
export * from './account-block.model' export * from './account-block.model'
export * from './account-blocklist.component' export * from './account-blocklist.component'
export * from './batch-domains-modal.component' export * from './batch-domains-modal.component'
@ -6,7 +7,6 @@ export * from './bulk.service'
export * from './server-blocklist.component' export * from './server-blocklist.component'
export * from './user-ban-modal.component' export * from './user-ban-modal.component'
export * from './user-moderation-dropdown.component' export * from './user-moderation-dropdown.component'
export * from './video-abuse.service'
export * from './video-block.component' export * from './video-block.component'
export * from './video-block.service' export * from './video-block.service'
export * from './video-report.component' export * from './video-report.component'

View File

@ -8,7 +8,7 @@ import { BlocklistService } from './blocklist.service'
import { BulkService } from './bulk.service' import { BulkService } from './bulk.service'
import { UserBanModalComponent } from './user-ban-modal.component' import { UserBanModalComponent } from './user-ban-modal.component'
import { UserModerationDropdownComponent } from './user-moderation-dropdown.component' import { UserModerationDropdownComponent } from './user-moderation-dropdown.component'
import { VideoAbuseService } from './video-abuse.service' import { AbuseService } from './abuse.service'
import { VideoBlockComponent } from './video-block.component' import { VideoBlockComponent } from './video-block.component'
import { VideoBlockService } from './video-block.service' import { VideoBlockService } from './video-block.service'
import { VideoReportComponent } from './video-report.component' import { VideoReportComponent } from './video-report.component'
@ -39,7 +39,7 @@ import { VideoReportComponent } from './video-report.component'
providers: [ providers: [
BlocklistService, BlocklistService,
BulkService, BulkService,
VideoAbuseService, AbuseService,
VideoBlockService VideoBlockService
] ]
}) })

View File

@ -3,13 +3,13 @@ import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
import { Component, Input, OnInit, ViewChild } from '@angular/core' import { Component, Input, OnInit, ViewChild } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser' import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { Notifier } from '@app/core' import { Notifier } from '@app/core'
import { FormReactive, FormValidatorService, VideoAbuseValidatorsService } from '@app/shared/shared-forms' import { AbuseValidatorsService, FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { I18n } from '@ngx-translate/i18n-polyfill' import { I18n } from '@ngx-translate/i18n-polyfill'
import { videoAbusePredefinedReasonsMap, VideoAbusePredefinedReasonsString } from '@shared/models/videos/abuse/video-abuse-reason.model' import { abusePredefinedReasonsMap, AbusePredefinedReasonsString } from '@shared/models'
import { Video } from '../shared-main' import { Video } from '../shared-main'
import { VideoAbuseService } from './video-abuse.service' import { AbuseService } from './abuse.service'
@Component({ @Component({
selector: 'my-video-report', selector: 'my-video-report',
@ -22,7 +22,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal @ViewChild('modal', { static: true }) modal: NgbModal
error: string = null error: string = null
predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] predefinedReasons: { id: AbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = []
embedHtml: SafeHtml embedHtml: SafeHtml
private openedModal: NgbModalRef private openedModal: NgbModalRef
@ -30,8 +30,8 @@ export class VideoReportComponent extends FormReactive implements OnInit {
constructor ( constructor (
protected formValidatorService: FormValidatorService, protected formValidatorService: FormValidatorService,
private modalService: NgbModal, private modalService: NgbModal,
private videoAbuseValidatorsService: VideoAbuseValidatorsService, private abuseValidatorsService: AbuseValidatorsService,
private videoAbuseService: VideoAbuseService, private abuseService: AbuseService,
private notifier: Notifier, private notifier: Notifier,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private i18n: I18n private i18n: I18n
@ -69,8 +69,8 @@ export class VideoReportComponent extends FormReactive implements OnInit {
ngOnInit () { ngOnInit () {
this.buildForm({ this.buildForm({
reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON, reason: this.abuseValidatorsService.ABUSE_REASON,
predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null), predefinedReasons: mapValues(abusePredefinedReasonsMap, r => null),
timestamp: { timestamp: {
hasStart: null, hasStart: null,
startAt: null, startAt: null,
@ -136,15 +136,18 @@ export class VideoReportComponent extends FormReactive implements OnInit {
report () { report () {
const reason = this.form.get('reason').value const reason = this.form.get('reason').value
const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[] const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as AbusePredefinedReasonsString[]
const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value
this.videoAbuseService.reportVideo({ this.abuseService.reportVideo({
id: this.video.id, accountId: this.video.account.id,
reason, reason,
predefinedReasons, predefinedReasons,
startAt: hasStart && startAt ? startAt : undefined, video: {
endAt: hasEnd && endAt ? endAt : undefined id: this.video.id,
startAt: hasStart && startAt ? startAt : undefined,
endAt: hasEnd && endAt ? endAt : undefined
}
}).subscribe( }).subscribe(
() => { () => {
this.notifier.success(this.i18n('Video reported.')) this.notifier.success(this.i18n('Video reported.'))

View File

@ -0,0 +1,168 @@
import * as express from 'express'
import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
import { AbuseModel } from '@server/models/abuse/abuse'
import { getServerActor } from '@server/models/application/application'
import { AbuseCreate, abusePredefinedReasonsMap, AbuseState, UserRight } from '../../../shared'
import { getFormattedObjects } from '../../helpers/utils'
import { sequelizeTypescript } from '../../initializers/database'
import {
abuseGetValidator,
abuseListValidator,
abuseReportValidator,
abusesSortValidator,
abuseUpdateValidator,
asyncMiddleware,
asyncRetryTransactionMiddleware,
authenticate,
ensureUserHasRight,
paginationValidator,
setDefaultPagination,
setDefaultSort
} from '../../middlewares'
import { AccountModel } from '../../models/account/account'
const abuseRouter = express.Router()
abuseRouter.get('/abuse',
authenticate,
ensureUserHasRight(UserRight.MANAGE_ABUSES),
paginationValidator,
abusesSortValidator,
setDefaultSort,
setDefaultPagination,
abuseListValidator,
asyncMiddleware(listAbuses)
)
abuseRouter.put('/:videoId/abuse/:id',
authenticate,
ensureUserHasRight(UserRight.MANAGE_ABUSES),
asyncMiddleware(abuseUpdateValidator),
asyncRetryTransactionMiddleware(updateAbuse)
)
abuseRouter.post('/:videoId/abuse',
authenticate,
asyncMiddleware(abuseReportValidator),
asyncRetryTransactionMiddleware(reportAbuse)
)
abuseRouter.delete('/:videoId/abuse/: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: 'video',
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 video 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 video 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 } })
}

View File

@ -3,6 +3,7 @@ import * as express from 'express'
import * as RateLimit from 'express-rate-limit' import * as RateLimit from 'express-rate-limit'
import { badRequest } from '../../helpers/express-utils' import { badRequest } from '../../helpers/express-utils'
import { CONFIG } from '../../initializers/config' import { CONFIG } from '../../initializers/config'
import { abuseRouter } from './abuse'
import { accountsRouter } from './accounts' import { accountsRouter } from './accounts'
import { bulkRouter } from './bulk' import { bulkRouter } from './bulk'
import { configRouter } from './config' import { configRouter } from './config'
@ -32,6 +33,7 @@ const apiRateLimiter = RateLimit({
apiRouter.use(apiRateLimiter) apiRouter.use(apiRateLimiter)
apiRouter.use('/server', serverRouter) apiRouter.use('/server', serverRouter)
apiRouter.use('/abuses', abuseRouter)
apiRouter.use('/bulk', bulkRouter) apiRouter.use('/bulk', bulkRouter)
apiRouter.use('/oauth-clients', oauthClientsRouter) apiRouter.use('/oauth-clients', oauthClientsRouter)
apiRouter.use('/config', configRouter) apiRouter.use('/config', configRouter)

View File

@ -1,9 +1,10 @@
import * as express from 'express' import * as express from 'express'
import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse, videoAbusePredefinedReasonsMap } from '../../../../shared' import { AbuseModel } from '@server/models/abuse/abuse'
import { logger } from '../../../helpers/logger' import { getServerActor } from '@server/models/application/application'
import { AbuseCreate, UserRight, VideoAbuseCreate } from '../../../../shared'
import { getFormattedObjects } from '../../../helpers/utils' import { getFormattedObjects } from '../../../helpers/utils'
import { sequelizeTypescript } from '../../../initializers/database'
import { import {
abusesSortValidator,
asyncMiddleware, asyncMiddleware,
asyncRetryTransactionMiddleware, asyncRetryTransactionMiddleware,
authenticate, authenticate,
@ -12,28 +13,21 @@ import {
setDefaultPagination, setDefaultPagination,
setDefaultSort, setDefaultSort,
videoAbuseGetValidator, videoAbuseGetValidator,
videoAbuseListValidator,
videoAbuseReportValidator, videoAbuseReportValidator,
videoAbusesSortValidator, videoAbuseUpdateValidator
videoAbuseUpdateValidator,
videoAbuseListValidator
} from '../../../middlewares' } from '../../../middlewares'
import { AccountModel } from '../../../models/account/account' import { deleteAbuse, reportAbuse, updateAbuse } from '../abuse'
import { VideoAbuseModel } from '../../../models/video/video-abuse'
import { auditLoggerFactory, VideoAbuseAuditView } from '../../../helpers/audit-logger' // FIXME: deprecated in 2.3. Remove this controller
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'
const auditLogger = auditLoggerFactory('abuse')
const abuseVideoRouter = express.Router() const abuseVideoRouter = express.Router()
abuseVideoRouter.get('/abuse', abuseVideoRouter.get('/abuse',
authenticate, authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES), ensureUserHasRight(UserRight.MANAGE_ABUSES),
paginationValidator, paginationValidator,
videoAbusesSortValidator, abusesSortValidator,
setDefaultSort, setDefaultSort,
setDefaultPagination, setDefaultPagination,
videoAbuseListValidator, videoAbuseListValidator,
@ -41,7 +35,7 @@ abuseVideoRouter.get('/abuse',
) )
abuseVideoRouter.put('/:videoId/abuse/:id', abuseVideoRouter.put('/:videoId/abuse/:id',
authenticate, authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES), ensureUserHasRight(UserRight.MANAGE_ABUSES),
asyncMiddleware(videoAbuseUpdateValidator), asyncMiddleware(videoAbuseUpdateValidator),
asyncRetryTransactionMiddleware(updateVideoAbuse) asyncRetryTransactionMiddleware(updateVideoAbuse)
) )
@ -52,7 +46,7 @@ abuseVideoRouter.post('/:videoId/abuse',
) )
abuseVideoRouter.delete('/:videoId/abuse/:id', abuseVideoRouter.delete('/:videoId/abuse/:id',
authenticate, authenticate,
ensureUserHasRight(UserRight.MANAGE_VIDEO_ABUSES), ensureUserHasRight(UserRight.MANAGE_ABUSES),
asyncMiddleware(videoAbuseGetValidator), asyncMiddleware(videoAbuseGetValidator),
asyncRetryTransactionMiddleware(deleteVideoAbuse) asyncRetryTransactionMiddleware(deleteVideoAbuse)
) )
@ -69,11 +63,12 @@ async function listVideoAbuses (req: express.Request, res: express.Response) {
const user = res.locals.oauth.token.user const user = res.locals.oauth.token.user
const serverActor = await getServerActor() const serverActor = await getServerActor()
const resultList = await VideoAbuseModel.listForApi({ const resultList = await AbuseModel.listForApi({
start: req.query.start, start: req.query.start,
count: req.query.count, count: req.query.count,
sort: req.query.sort, sort: req.query.sort,
id: req.query.id, id: req.query.id,
filter: 'video',
predefinedReason: req.query.predefinedReason, predefinedReason: req.query.predefinedReason,
search: req.query.search, search: req.query.search,
state: req.query.state, state: req.query.state,
@ -90,74 +85,28 @@ async function listVideoAbuses (req: express.Request, res: express.Response) {
} }
async function updateVideoAbuse (req: express.Request, res: express.Response) { async function updateVideoAbuse (req: express.Request, res: express.Response) {
const videoAbuse = res.locals.videoAbuse return updateAbuse(req, res)
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()
} }
async function deleteVideoAbuse (req: express.Request, res: express.Response) { async function deleteVideoAbuse (req: express.Request, res: express.Response) {
const videoAbuse = res.locals.videoAbuse return deleteAbuse(req, res)
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()
} }
async function reportVideoAbuse (req: express.Request, res: express.Response) { async function reportVideoAbuse (req: express.Request, res: express.Response) {
const videoInstance = res.locals.videoAll const oldBody = req.body as VideoAbuseCreate
const body: VideoAbuseCreate = req.body
let reporterAccount: MAccountDefault
let videoAbuseJSON: VideoAbuse
const videoAbuseInstance = await sequelizeTypescript.transaction(async t => { req.body = {
reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) accountId: res.locals.videoAll.VideoChannel.accountId,
const predefinedReasons = body.predefinedReasons?.map(r => videoAbusePredefinedReasonsMap[r])
const abuseToCreate = { reason: oldBody.reason,
reporterAccountId: reporterAccount.id, predefinedReasons: oldBody.predefinedReasons,
reason: body.reason,
videoId: videoInstance.id, video: {
state: VideoAbuseState.PENDING, id: res.locals.videoAll.id,
predefinedReasons, startAt: oldBody.startAt,
startAt: body.startAt, endAt: oldBody.endAt
endAt: body.endAt
} }
} as AbuseCreate
const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(abuseToCreate, { transaction: t }) return reportAbuse(req, res)
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)
}
videoAbuseJSON = videoAbuseInstance.toFormattedJSON()
auditLogger.create(reporterAccount.Actor.getIdentifier(), new VideoAbuseAuditView(videoAbuseJSON))
return videoAbuseInstance
})
Notifier.Instance.notifyOnNewVideoAbuse({
videoAbuse: videoAbuseJSON,
videoAbuseInstance,
reporter: reporterAccount.Actor.getIdentifier()
})
logger.info('Abuse report for video "%s" created.', videoInstance.name)
return res.json({ videoAbuse: videoAbuseJSON }).end()
} }

View File

@ -1,15 +1,15 @@
import * as path from 'path'
import * as express from 'express'
import { diff } from 'deep-object-diff' import { diff } from 'deep-object-diff'
import { chain } from 'lodash' import * as express from 'express'
import * as flatten from 'flat' import * as flatten from 'flat'
import { chain } from 'lodash'
import * as path from 'path'
import * as winston from 'winston' 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 { CustomConfig } from '../../shared/models/server/custom-config.model'
import { CONFIG } from '../initializers/config'
import { AUDIT_LOG_FILENAME } from '@server/initializers/constants' 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 { jsonLoggerFormat, labelFormatter } from './logger'
function getAuditIdFromRes (res: express.Response) { function getAuditIdFromRes (res: express.Response) {
return res.locals.oauth.token.User.username return res.locals.oauth.token.User.username
@ -212,18 +212,15 @@ class VideoChannelAuditView extends EntityAuditView {
} }
} }
const videoAbuseKeysToKeep = [ const abuseKeysToKeep = [
'id', 'id',
'reason', 'reason',
'reporterAccount', 'reporterAccount',
'video-id',
'video-name',
'video-uuid',
'createdAt' 'createdAt'
] ]
class VideoAbuseAuditView extends EntityAuditView { class AbuseAuditView extends EntityAuditView {
constructor (private readonly videoAbuse: VideoAbuse) { constructor (private readonly abuse: Abuse) {
super(videoAbuseKeysToKeep, 'abuse', videoAbuse) super(abuseKeysToKeep, 'abuse', abuse)
} }
} }
@ -274,6 +271,6 @@ export {
CommentAuditView, CommentAuditView,
UserAuditView, UserAuditView,
VideoAuditView, VideoAuditView,
VideoAbuseAuditView, AbuseAuditView,
CustomConfigAuditView CustomConfigAuditView
} }

View File

@ -0,0 +1,54 @@
import validator from 'validator'
import { abusePredefinedReasonsMap, AbusePredefinedReasonsString, AbuseVideoIs } from '@shared/models'
import { CONSTRAINTS_FIELDS, ABUSE_STATES } from '../../initializers/constants'
import { exists, isArray } from './misc'
const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.ABUSES
function isAbuseReasonValid (value: string) {
return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON)
}
function isAbusePredefinedReasonValid (value: AbusePredefinedReasonsString) {
return exists(value) && value in abusePredefinedReasonsMap
}
function isAbusePredefinedReasonsValid (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 }) {
return exists(req.body.startAt) && endAt > req.body.startAt
}
function isAbuseModerationCommentValid (value: string) {
return exists(value) && validator.isLength(value, VIDEO_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,
isAbusePredefinedReasonValid,
isAbusePredefinedReasonsValid,
isAbuseTimestampValid,
isAbuseTimestampCoherent,
isAbuseModerationCommentValid,
isAbuseStateValid,
isAbuseVideoIsValid
}

View File

@ -1,9 +1,9 @@
import { isActivityPubUrlValid } from './misc' import { isActivityPubUrlValid } from './misc'
import { isVideoAbuseReasonValid } from '../video-abuses' import { isAbuseReasonValid } from '../abuses'
function isFlagActivityValid (activity: any) { function isFlagActivityValid (activity: any) {
return activity.type === 'Flag' && return activity.type === 'Flag' &&
isVideoAbuseReasonValid(activity.content) && isAbuseReasonValid(activity.content) &&
isActivityPubUrlValid(activity.object) isActivityPubUrlValid(activity.object)
} }

View File

@ -1,56 +0,0 @@
import validator from 'validator'
import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
import { exists, isArray } from './misc'
import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES
function isVideoAbuseReasonValid (value: string) {
return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON)
}
function isVideoAbusePredefinedReasonValid (value: VideoAbusePredefinedReasonsString) {
return exists(value) && value in videoAbusePredefinedReasonsMap
}
function isVideoAbusePredefinedReasonsValid (value: VideoAbusePredefinedReasonsString[]) {
return exists(value) && isArray(value) && value.every(v => v in videoAbusePredefinedReasonsMap)
}
function isVideoAbuseTimestampValid (value: number) {
return value === null || (exists(value) && validator.isInt('' + value, { min: 0 }))
}
function isVideoAbuseTimestampCoherent (endAt: number, { req }) {
return exists(req.body.startAt) && endAt > req.body.startAt
}
function isVideoAbuseModerationCommentValid (value: string) {
return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT)
}
function isVideoAbuseStateValid (value: string) {
return exists(value) && VIDEO_ABUSE_STATES[value] !== undefined
}
function isAbuseVideoIsValid (value: VideoAbuseVideoIs) {
return exists(value) && (
value === 'deleted' ||
value === 'blacklisted'
)
}
// ---------------------------------------------------------------------------
export {
isVideoAbuseReasonValid,
isVideoAbusePredefinedReasonValid,
isVideoAbusePredefinedReasonsValid,
isVideoAbuseTimestampValid,
isVideoAbuseTimestampCoherent,
isVideoAbuseModerationCommentValid,
isVideoAbuseStateValid,
isAbuseVideoIsValid
}

View File

@ -1,19 +1,20 @@
import { Response } from 'express' import { Response } from 'express'
import { VideoAbuseModel } from '../../models/video/video-abuse' import { AbuseModel } from '../../models/abuse/abuse'
import { fetchVideo } from '../video' import { fetchVideo } from '../video'
// FIXME: deprecated in 2.3. Remove this function
async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) { async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) {
const abuseId = parseInt(abuseIdArg + '', 10) const abuseId = parseInt(abuseIdArg + '', 10)
let videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, null, videoUUID) let abuse = await AbuseModel.loadByIdAndVideoId(abuseId, null, videoUUID)
if (!videoAbuse) { if (!abuse) {
const userId = res.locals.oauth?.token.User.id const userId = res.locals.oauth?.token.User.id
const video = await fetchVideo(videoUUID, 'all', userId) const video = await fetchVideo(videoUUID, 'all', userId)
if (video) videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, video.id) if (video) abuse = await AbuseModel.loadByIdAndVideoId(abuseId, video.id)
} }
if (videoAbuse === null) { if (abuse === null) {
res.status(404) res.status(404)
.json({ error: 'Video abuse not found' }) .json({ error: 'Video abuse not found' })
.end() .end()
@ -21,12 +22,17 @@ async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: stri
return false return false
} }
res.locals.videoAbuse = videoAbuse res.locals.abuse = abuse
return true return true
} }
async function doesAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) {
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
doesAbuseExist,
doesVideoAbuseExist doesVideoAbuseExist
} }

View File

@ -1,5 +1,5 @@
export * from './abuses'
export * from './accounts' export * from './accounts'
export * from './video-abuses'
export * from './video-blacklists' export * from './video-blacklists'
export * from './video-captions' export * from './video-captions'
export * from './video-channels' export * from './video-channels'

View File

@ -1,9 +1,17 @@
import { join } from 'path' import { join } from 'path'
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { JobType, VideoRateType, VideoResolution, VideoState } from '../../shared/models'
import { ActivityPubActorType } from '../../shared/models/activitypub' import { ActivityPubActorType } from '../../shared/models/activitypub'
import { FollowState } from '../../shared/models/actors' 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 // Do not use barrels, remain constants as independent as possible
import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils' import { isTestInstance, sanitizeHost, sanitizeUrl, root } from '../helpers/core-utils'
import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type' import { NSFWPolicyType } from '../../shared/models/videos/nsfw-policy.type'
@ -51,7 +59,6 @@ const SORTABLE_COLUMNS = {
USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ], USER_SUBSCRIPTIONS: [ 'id', 'createdAt' ],
ACCOUNTS: [ 'createdAt' ], ACCOUNTS: [ 'createdAt' ],
JOBS: [ 'createdAt' ], JOBS: [ 'createdAt' ],
VIDEO_ABUSES: [ 'id', 'createdAt', 'state' ],
VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ], VIDEO_CHANNELS: [ 'id', 'name', 'updatedAt', 'createdAt' ],
VIDEO_IMPORTS: [ 'createdAt' ], VIDEO_IMPORTS: [ 'createdAt' ],
VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ], VIDEO_COMMENT_THREADS: [ 'createdAt', 'totalReplies' ],
@ -66,6 +73,8 @@ const SORTABLE_COLUMNS = {
VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
ABUSES: [ 'id', 'createdAt', 'state' ],
ACCOUNTS_BLOCKLIST: [ 'createdAt' ], ACCOUNTS_BLOCKLIST: [ 'createdAt' ],
SERVERS_BLOCKLIST: [ 'createdAt' ], SERVERS_BLOCKLIST: [ 'createdAt' ],
@ -193,7 +202,7 @@ const CONSTRAINTS_FIELDS = {
VIDEO_LANGUAGES: { max: 500 }, // Array length VIDEO_LANGUAGES: { max: 500 }, // Array length
BLOCKED_REASON: { min: 3, max: 250 } // Length BLOCKED_REASON: { min: 3, max: 250 } // Length
}, },
VIDEO_ABUSES: { ABUSES: {
REASON: { min: 2, max: 3000 }, // Length REASON: { min: 2, max: 3000 }, // Length
MODERATION_COMMENT: { min: 2, max: 3000 } // Length MODERATION_COMMENT: { min: 2, max: 3000 } // Length
}, },
@ -378,10 +387,10 @@ const VIDEO_IMPORT_STATES = {
[VideoImportState.REJECTED]: 'Rejected' [VideoImportState.REJECTED]: 'Rejected'
} }
const VIDEO_ABUSE_STATES = { const ABUSE_STATES = {
[VideoAbuseState.PENDING]: 'Pending', [AbuseState.PENDING]: 'Pending',
[VideoAbuseState.REJECTED]: 'Rejected', [AbuseState.REJECTED]: 'Rejected',
[VideoAbuseState.ACCEPTED]: 'Accepted' [AbuseState.ACCEPTED]: 'Accepted'
} }
const VIDEO_PLAYLIST_PRIVACIES = { const VIDEO_PLAYLIST_PRIVACIES = {
@ -778,7 +787,7 @@ export {
VIDEO_RATE_TYPES, VIDEO_RATE_TYPES,
VIDEO_TRANSCODING_FPS, VIDEO_TRANSCODING_FPS,
FFMPEG_NICE, FFMPEG_NICE,
VIDEO_ABUSE_STATES, ABUSE_STATES,
VIDEO_CHANNELS, VIDEO_CHANNELS,
LRU_CACHE, LRU_CACHE,
JOB_REQUEST_TIMEOUT, JOB_REQUEST_TIMEOUT,

View File

@ -1,44 +1,45 @@
import { QueryTypes, Transaction } from 'sequelize'
import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' 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 { isTestInstance } from '../helpers/core-utils'
import { logger } from '../helpers/logger' import { logger } from '../helpers/logger'
import { AccountModel } from '../models/account/account' import { AccountModel } from '../models/account/account'
import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { AccountVideoRateModel } from '../models/account/account-video-rate' import { AccountVideoRateModel } from '../models/account/account-video-rate'
import { UserModel } from '../models/account/user' 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 { ActorModel } from '../models/activitypub/actor'
import { ActorFollowModel } from '../models/activitypub/actor-follow' import { ActorFollowModel } from '../models/activitypub/actor-follow'
import { ApplicationModel } from '../models/application/application' import { ApplicationModel } from '../models/application/application'
import { AvatarModel } from '../models/avatar/avatar' import { AvatarModel } from '../models/avatar/avatar'
import { OAuthClientModel } from '../models/oauth/oauth-client' import { OAuthClientModel } from '../models/oauth/oauth-client'
import { OAuthTokenModel } from '../models/oauth/oauth-token' 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 { 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 { TagModel } from '../models/video/tag'
import { ThumbnailModel } from '../models/video/thumbnail'
import { VideoModel } from '../models/video/video' import { VideoModel } from '../models/video/video'
import { VideoAbuseModel } from '../models/video/video-abuse'
import { VideoBlacklistModel } from '../models/video/video-blacklist' 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 { VideoChannelModel } from '../models/video/video-channel'
import { VideoCommentModel } from '../models/video/video-comment' import { VideoCommentModel } from '../models/video/video-comment'
import { VideoFileModel } from '../models/video/video-file' 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 { 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 { VideoPlaylistModel } from '../models/video/video-playlist'
import { VideoPlaylistElementModel } from '../models/video/video-playlist-element' import { VideoPlaylistElementModel } from '../models/video/video-playlist-element'
import { ThumbnailModel } from '../models/video/thumbnail' import { VideoShareModel } from '../models/video/video-share'
import { PluginModel } from '../models/server/plugin' import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist'
import { QueryTypes, Transaction } from 'sequelize' 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 require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
@ -86,6 +87,8 @@ async function initDatabaseModels (silent: boolean) {
TagModel, TagModel,
AccountVideoRateModel, AccountVideoRateModel,
UserModel, UserModel,
AbuseModel,
VideoCommentAbuseModel,
VideoAbuseModel, VideoAbuseModel,
VideoModel, VideoModel,
VideoChangeOwnershipModel, VideoChangeOwnershipModel,

View File

@ -1,5 +1,5 @@
import * as Sequelize from 'sequelize' import * as Sequelize from 'sequelize'
import { VideoAbuseState } from '../../../shared/models/videos' import { AbuseState } from '../../../shared/models'
async function up (utils: { async function up (utils: {
transaction: Sequelize.Transaction transaction: Sequelize.Transaction
@ -16,7 +16,7 @@ async function up (utils: {
} }
{ {
const query = 'UPDATE "videoAbuse" SET "state" = ' + VideoAbuseState.PENDING const query = 'UPDATE "videoAbuse" SET "state" = ' + AbuseState.PENDING
await utils.sequelize.query(query) await utils.sequelize.query(query)
} }

View File

@ -1,24 +1,19 @@
import { import { createAccountAbuse, createVideoAbuse, createVideoCommentAbuse } from '@server/lib/moderation'
ActivityCreate, import { AccountModel } from '@server/models/account/account'
ActivityFlag, import { VideoModel } from '@server/models/video/video'
VideoAbuseState, import { VideoCommentModel } from '@server/models/video/video-comment'
videoAbusePredefinedReasonsMap import { AbuseObject, abusePredefinedReasonsMap, AbuseState, ActivityCreate, ActivityFlag } from '../../../../shared'
} from '../../../../shared' import { getAPId } from '../../../helpers/activitypub'
import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects'
import { retryTransactionWrapper } from '../../../helpers/database-utils' import { retryTransactionWrapper } from '../../../helpers/database-utils'
import { logger } from '../../../helpers/logger' import { logger } from '../../../helpers/logger'
import { sequelizeTypescript } from '../../../initializers/database' 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 { APProcessorOptions } from '../../../types/activitypub-processor.model'
import { MActorSignature, MVideoAbuseAccountVideo } from '../../../types/models' import { MAccountDefault, MActorSignature, MCommentOwnerVideo } from '../../../types/models'
import { AccountModel } from '@server/models/account/account'
async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) { async function processFlagActivity (options: APProcessorOptions<ActivityCreate | ActivityFlag>) {
const { activity, byActor } = options const { activity, byActor } = options
return retryTransactionWrapper(processCreateVideoAbuse, activity, byActor)
return retryTransactionWrapper(processCreateAbuse, activity, byActor)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -29,55 +24,79 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag, byActor: MActorSignature) { async function processCreateAbuse (activity: ActivityCreate | ActivityFlag, byActor: MActorSignature) {
const flag = activity.type === 'Flag' ? activity : (activity.object as VideoAbuseObject) const flag = activity.type === 'Flag' ? activity : (activity.object as AbuseObject)
const account = byActor.Account 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 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) { for (const object of objects) {
try { try {
logger.debug('Reporting remote abuse for video %s.', getAPId(object)) const uri = getAPId(object)
const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object }) logger.debug('Reporting remote abuse for object %s.', uri)
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 => { await sequelizeTypescript.transaction(async t => {
const videoAbuseData = {
reporterAccountId: account.id, const video = await VideoModel.loadByUrlAndPopulateAccount(uri)
reason: flag.content, let videoComment: MCommentOwnerVideo
videoId: video.id, let flaggedAccount: MAccountDefault
state: VideoAbuseState.PENDING,
predefinedReasons, if (!video) videoComment = await VideoCommentModel.loadByUrlAndPopulateAccountAndVideo(uri)
startAt, if (!videoComment) flaggedAccount = await AccountModel.loadByUrl(uri)
endAt
if (!video && !videoComment && !flaggedAccount) {
logger.warn('Cannot flag unknown entity %s.', object)
return
} }
const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(videoAbuseData, { transaction: t }) const baseAbuse = {
videoAbuseInstance.Video = video reporterAccountId: reporterAccount.id,
videoAbuseInstance.Account = reporterAccount reason: flag.content,
state: AbuseState.PENDING,
predefinedReasons
}
logger.info('Remote abuse for video uuid %s created', flag.object) if (video) {
return createVideoAbuse({
baseAbuse,
startAt,
endAt,
reporterAccount,
transaction: t,
videoInstance: video
})
}
return videoAbuseInstance if (videoComment) {
}) return createVideoCommentAbuse({
baseAbuse,
reporterAccount,
transaction: t,
commentInstance: videoComment
})
}
const videoAbuseJSON = videoAbuseInstance.toFormattedJSON() return await createAccountAbuse({
baseAbuse,
Notifier.Instance.notifyOnNewVideoAbuse({ reporterAccount,
videoAbuse: videoAbuseJSON, transaction: t,
videoAbuseInstance, accountInstance: flaggedAccount
reporter: reporterAccount.Actor.getIdentifier() })
}) })
} catch (err) { } 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 })
} }
} }
} }

View File

@ -1,32 +1,31 @@
import { getVideoAbuseActivityPubUrl } from '../url'
import { unicastTo } from './utils'
import { logger } from '../../../helpers/logger'
import { ActivityAudience, ActivityFlag } from '../../../../shared/models/activitypub'
import { audiencify, getAudience } from '../audience'
import { Transaction } from 'sequelize' import { Transaction } from 'sequelize'
import { MActor, MVideoFullLight } from '../../../types/models' import { ActivityAudience, ActivityFlag } from '../../../../shared/models/activitypub'
import { MVideoAbuseVideo } from '../../../types/models/video' import { logger } from '../../../helpers/logger'
import { MAbuseAP, MAccountLight, MActor } from '../../../types/models'
import { audiencify, getAudience } from '../audience'
import { getAbuseActivityPubUrl } from '../url'
import { unicastTo } from './utils'
function sendVideoAbuse (byActor: MActor, videoAbuse: MVideoAbuseVideo, video: MVideoFullLight, t: Transaction) { function sendAbuse (byActor: MActor, abuse: MAbuseAP, flaggedAccount: MAccountLight, t: Transaction) {
if (!video.VideoChannel.Account.Actor.serverId) return // Local user 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 // Custom audience, we only send the abuse to the origin instance
const audience = { to: [ video.VideoChannel.Account.Actor.url ], cc: [] } const audience = { to: [ flaggedAccount.Actor.url ], cc: [] }
const flagActivity = buildFlagActivity(url, byActor, videoAbuse, audience) 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) if (!audience) audience = getAudience(byActor)
const activity = Object.assign( const activity = Object.assign(
{ id: url, actor: byActor.url }, { id: url, actor: byActor.url },
videoAbuse.toActivityPubObject() abuse.toActivityPubObject()
) )
return audiencify(activity, audience) return audiencify(activity, audience)
@ -35,5 +34,5 @@ function buildFlagActivity (url: string, byActor: MActor, videoAbuse: MVideoAbus
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
sendVideoAbuse sendAbuse
} }

View File

@ -5,10 +5,10 @@ import {
MActorId, MActorId,
MActorUrl, MActorUrl,
MCommentId, MCommentId,
MVideoAbuseId,
MVideoId, MVideoId,
MVideoUrl, MVideoUrl,
MVideoUUID MVideoUUID,
MAbuseId
} from '../../types/models' } from '../../types/models'
import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist' import { MVideoPlaylist, MVideoPlaylistUUID } from '../../types/models/video/video-playlist'
import { MVideoFileVideoUUID } from '../../types/models/video/video-file' import { MVideoFileVideoUUID } from '../../types/models/video/video-file'
@ -48,8 +48,8 @@ function getAccountActivityPubUrl (accountName: string) {
return WEBSERVER.URL + '/accounts/' + accountName return WEBSERVER.URL + '/accounts/' + accountName
} }
function getVideoAbuseActivityPubUrl (videoAbuse: MVideoAbuseId) { function getAbuseActivityPubUrl (abuse: MAbuseId) {
return WEBSERVER.URL + '/admin/video-abuses/' + videoAbuse.id return WEBSERVER.URL + '/admin/abuses/' + abuse.id
} }
function getVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId) { function getVideoViewActivityPubUrl (byActor: MActorUrl, video: MVideoId) {
@ -118,7 +118,7 @@ export {
getVideoCacheStreamingPlaylistActivityPubUrl, getVideoCacheStreamingPlaylistActivityPubUrl,
getVideoChannelActivityPubUrl, getVideoChannelActivityPubUrl,
getAccountActivityPubUrl, getAccountActivityPubUrl,
getVideoAbuseActivityPubUrl, getAbuseActivityPubUrl,
getActorFollowActivityPubUrl, getActorFollowActivityPubUrl,
getActorFollowAcceptActivityPubUrl, getActorFollowAcceptActivityPubUrl,
getVideoAnnounceActivityPubUrl, getVideoAnnounceActivityPubUrl,

View File

@ -1,26 +1,20 @@
import { readFileSync } from 'fs-extra'
import { merge } from 'lodash'
import { createTransport, Transporter } from 'nodemailer' 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 { isTestInstance, root } from '../helpers/core-utils'
import { bunyanLogger, logger } from '../helpers/logger' import { bunyanLogger, logger } from '../helpers/logger'
import { CONFIG, isEmailEnabled } from '../initializers/config' import { CONFIG, isEmailEnabled } from '../initializers/config'
import { JobQueue } from './job-queue'
import { readFileSync } from 'fs-extra'
import { WEBSERVER } from '../initializers/constants' import { WEBSERVER } from '../initializers/constants'
import { import { MAbuseFull, MActorFollowActors, MActorFollowFull, MUser } from '../types/models'
MCommentOwnerVideo, import { MCommentOwnerVideo, MVideo, MVideoAccountLight } from '../types/models/video'
MVideo, import { JobQueue } from './job-queue'
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'
const Email = require('email-templates') const Email = require('email-templates')
class Emailer { class Emailer {
@ -288,28 +282,70 @@ class Emailer {
return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload }) return JobQueue.Instance.createJob({ type: 'email', payload: emailPayload })
} }
addVideoAbuseModeratorsNotification (to: string[], parameters: { addAbuseModeratorsNotification (to: string[], parameters: {
videoAbuse: VideoAbuse abuse: Abuse
videoAbuseInstance: MVideoAbuseVideo abuseInstance: MAbuseFull
reporter: string reporter: string
}) { }) {
const videoAbuseUrl = WEBSERVER.URL + '/admin/moderation/video-abuses/list?search=%23' + parameters.videoAbuse.id const { abuse, abuseInstance, reporter } = parameters
const videoUrl = WEBSERVER.URL + parameters.videoAbuseInstance.Video.getWatchStaticPath()
const emailPayload: EmailPayload = { const action = {
template: 'video-abuse-new', text: 'View report #' + abuse.id,
to, url: WEBSERVER.URL + '/admin/moderation/abuses/list?search=%23' + abuse.id
subject: `New video abuse report from ${parameters.reporter}`, }
locals: {
videoUrl, let emailPayload: EmailPayload
videoAbuseUrl,
videoCreatedAt: new Date(parameters.videoAbuseInstance.Video.createdAt).toLocaleString(), if (abuseInstance.VideoAbuse) {
videoPublishedAt: new Date(parameters.videoAbuseInstance.Video.publishedAt).toLocaleString(), const video = abuseInstance.VideoAbuse.Video
videoAbuse: parameters.videoAbuse, const videoUrl = WEBSERVER.URL + video.getWatchStaticPath()
reporter: parameters.reporter,
action: { emailPayload = {
text: 'View report #' + parameters.videoAbuse.id, template: 'video-abuse-new',
url: videoAbuseUrl 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: video.VideoChannel,
action
}
}
} else if (abuseInstance.VideoCommentAbuse) {
const comment = abuseInstance.VideoCommentAbuse.VideoComment
const commentUrl = WEBSERVER.URL + comment.Video.getWatchStaticPath() + ';threadId=' + comment.getThreadId()
emailPayload = {
template: 'comment-abuse-new',
to,
subject: `New comment abuse report from ${reporter}`,
locals: {
commentUrl,
isLocal: comment.isOwned(),
commentCreatedAt: new Date(comment.createdAt).toLocaleString(),
reason: abuse.reason,
flaggedAccount: abuseInstance.FlaggedAccount.getDisplayName(),
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,
action
} }
} }
} }

View File

@ -0,0 +1,14 @@
extends ../common/greetings
include ../common/mixins.pug
block title
| An account is pending moderation
block content
p
| #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}account "
a(href=accountUrl) #{accountDisplayName}
p The reporter, #{reporter}, cited the following reason(s):
blockquote #{reason}
br(style="display: none;")

View File

@ -1,3 +1,7 @@
mixin channel(channel) mixin channel(channel)
- var handle = `${channel.name}@${channel.host}` - var handle = `${channel.name}@${channel.host}`
| #[a(href=`${WEBSERVER.URL}/video-channels/${handle}` title=handle) #{channel.displayName}] | #[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}]

View File

@ -6,13 +6,13 @@ block title
block content block content
p p
| #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{videoAbuse.video.channel.isLocal ? '' : 'remote '}video " | #[a(href=WEBSERVER.URL) #{WEBSERVER.HOST}] received an abuse report for the #{isLocal ? '' : 'remote '}video "
a(href=videoUrl) #{videoAbuse.video.name} a(href=videoUrl) #{videoName}
| " by #[+channel(videoAbuse.video.channel)] | " by #[+channel(videoChannel)]
if videoPublishedAt if videoPublishedAt
| , published the #{videoPublishedAt}. | , published the #{videoPublishedAt}.
else else
| , uploaded the #{videoCreatedAt} but not yet published. | , uploaded the #{videoCreatedAt} but not yet published.
p The reporter, #{reporter}, cited the following reason(s): p The reporter, #{reporter}, cited the following reason(s):
blockquote #{videoAbuse.reason} blockquote #{reason}
br(style="display: none;") br(style="display: none;")

View File

@ -0,0 +1,15 @@
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 '}comment "
a(href=commentUrl) of #{flaggedAccount}
| created on #{commentCreatedAt}
p The reporter, #{reporter}, cited the following reason(s):
blockquote #{reason}
br(style="display: none;")

View File

@ -1,15 +1,33 @@
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 { ActorModel } from '../models/activitypub/actor'
import { VideoModel } from '../models/video/video' import { VideoModel } from '../models/video/video'
import { VideoCommentModel } from '../models/video/video-comment' import { VideoCommentModel } from '../models/video/video-comment'
import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model' import { sendAbuse } from './activitypub/send/send-flag'
import { VideoCreate, VideoImportCreate } from '../../shared/models/videos' import { Notifier } from './notifier'
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'
export type AcceptResult = { export type AcceptResult = {
accepted: boolean accepted: boolean
@ -73,6 +91,89 @@ function isPostImportVideoAccepted (object: {
return { accepted: true } 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 { export {
isLocalVideoAccepted, isLocalVideoAccepted,
isLocalVideoThreadAccepted, isLocalVideoThreadAccepted,
@ -80,5 +181,48 @@ export {
isRemoteVideoCommentAccepted, isRemoteVideoCommentAccepted,
isLocalVideoCommentReplyAccepted, isLocalVideoCommentReplyAccepted,
isPreImportVideoAccepted, 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
} }

View File

@ -8,23 +8,18 @@ import {
MUserWithNotificationSetting, MUserWithNotificationSetting,
UserNotificationModelForApi UserNotificationModelForApi
} from '@server/types/models/user' } 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 { MVideoImportVideo } from '@server/types/models/video/video-import'
import { Abuse } from '@shared/models'
import { UserNotificationSettingValue, UserNotificationType, UserRight } from '../../shared/models/users' 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 { logger } from '../helpers/logger'
import { CONFIG } from '../initializers/config' import { CONFIG } from '../initializers/config'
import { AccountBlocklistModel } from '../models/account/account-blocklist' import { AccountBlocklistModel } from '../models/account/account-blocklist'
import { UserModel } from '../models/account/user' import { UserModel } from '../models/account/user'
import { UserNotificationModel } from '../models/account/user-notification' import { UserNotificationModel } from '../models/account/user-notification'
import { MAccountServer, MActorFollowFull } from '../types/models' import { MAbuseFull, MAbuseVideo, MAccountServer, MActorFollowFull } from '../types/models'
import { import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../types/models/video'
MCommentOwnerVideo,
MVideoAbuseVideo,
MVideoAccountLight,
MVideoBlacklistLightVideo,
MVideoBlacklistVideo,
MVideoFullLight
} from '../types/models/video'
import { isBlockedByServerOrAccount } from './blocklist' import { isBlockedByServerOrAccount } from './blocklist'
import { Emailer } from './emailer' import { Emailer } from './emailer'
import { PeerTubeSocket } from './peertube-socket' import { PeerTubeSocket } from './peertube-socket'
@ -78,9 +73,9 @@ class Notifier {
.catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err })) .catch(err => logger.error('Cannot notify mentions of comment %s.', comment.url, { err }))
} }
notifyOnNewVideoAbuse (parameters: { videoAbuse: VideoAbuse, videoAbuseInstance: MVideoAbuseVideo, reporter: string }): void { notifyOnNewAbuse (parameters: { abuse: Abuse, abuseInstance: MAbuseFull, reporter: string }): void {
this.notifyModeratorsOfNewVideoAbuse(parameters) this.notifyModeratorsOfNewAbuse(parameters)
.catch(err => logger.error('Cannot notify of new video abuse of video %s.', parameters.videoAbuseInstance.Video.url, { err })) .catch(err => logger.error('Cannot notify of new abuse %d.', parameters.abuseInstance.id, { err }))
} }
notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void { notifyOnVideoAutoBlacklist (videoBlacklist: MVideoBlacklistLightVideo): void {
@ -354,33 +349,37 @@ class Notifier {
return this.notify({ users: admins, settingGetter, notificationCreator, emailSender }) return this.notify({ users: admins, settingGetter, notificationCreator, emailSender })
} }
private async notifyModeratorsOfNewVideoAbuse (parameters: { private async notifyModeratorsOfNewAbuse (parameters: {
videoAbuse: VideoAbuse abuse: Abuse
videoAbuseInstance: MVideoAbuseVideo abuseInstance: MAbuseFull
reporter: string 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 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
logger.info('Notifying %s user/moderators of new abuse %s.', moderators.length, url)
function settingGetter (user: MUserWithNotificationSetting) { function settingGetter (user: MUserWithNotificationSetting) {
return user.NotificationSetting.videoAbuseAsModerator return user.NotificationSetting.videoAbuseAsModerator
} }
async function notificationCreator (user: MUserWithNotificationSetting) { async function notificationCreator (user: MUserWithNotificationSetting) {
const notification: UserNotificationModelForApi = await UserNotificationModel.create<UserNotificationModelForApi>({ const notification = await UserNotificationModel.create<UserNotificationModelForApi>({
type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS, type: UserNotificationType.NEW_VIDEO_ABUSE_FOR_MODERATORS,
userId: user.id, userId: user.id,
videoAbuseId: parameters.videoAbuse.id abuseId: abuse.id
}) })
notification.VideoAbuse = parameters.videoAbuseInstance notification.Abuse = abuseInstance
return notification return notification
} }
function emailSender (emails: string[]) { function emailSender (emails: string[]) {
return Emailer.Instance.addVideoAbuseModeratorsNotification(emails, parameters) return Emailer.Instance.addAbuseModeratorsNotification(emails, parameters)
} }
return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender }) return this.notify({ users: moderators, settingGetter, notificationCreator, emailSender })

View File

@ -0,0 +1,253 @@
import * as express from 'express'
import { body, param, query } from 'express-validator'
import {
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 { logger } from '@server/helpers/logger'
import { doesAbuseExist, doesVideoAbuseExist, doesVideoExist } from '@server/helpers/middlewares'
import { areValidationErrors } from './utils'
const abuseReportValidator = [
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')
.bail()
.custom(isAbuseTimestampCoherent)
.withMessage('Should have a startAt timestamp beginning before endAt'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking abuseReport parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
// TODO: check comment or video (exlusive)
return next()
}
]
const abuseGetValidator = [
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 abuseGetValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
// if (!await doesAbuseExist(req.params.id, req.params.videoId, res)) return
return next()
}
]
const abuseUpdateValidator = [
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 abuseUpdateValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
// if (!await doesAbuseExist(req.params.id, req.params.videoId, res)) return
return next()
}
]
const abuseListValidator = [
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 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')
.bail()
.custom(isAbuseTimestampCoherent)
.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(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
}

View File

@ -1,3 +1,4 @@
export * from './abuse'
export * from './account' export * from './account'
export * from './blocklist' export * from './blocklist'
export * from './oembed' export * from './oembed'

View File

@ -5,7 +5,7 @@ import { checkSort, createSortableColumns } from './utils'
const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS) const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS)
const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS) const SORTABLE_ACCOUNTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ACCOUNTS)
const SORTABLE_JOBS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.JOBS) 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_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
@ -28,7 +28,7 @@ const SORTABLE_VIDEO_REDUNDANCIES_COLUMNS = createSortableColumns(SORTABLE_COLUM
const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS) const usersSortValidator = checkSort(SORTABLE_USERS_COLUMNS)
const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS) const accountsSortValidator = checkSort(SORTABLE_ACCOUNTS_COLUMNS)
const jobsSortValidator = checkSort(SORTABLE_JOBS_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 videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
@ -52,7 +52,7 @@ const videoRedundanciesSortValidator = checkSort(SORTABLE_VIDEO_REDUNDANCIES_COL
export { export {
usersSortValidator, usersSortValidator,
videoAbusesSortValidator, abusesSortValidator,
videoChannelsSortValidator, videoChannelsSortValidator,
videoImportsSortValidator, videoImportsSortValidator,
videosSearchSortValidator, videosSearchSortValidator,

View File

@ -1,4 +1,3 @@
export * from './video-abuses'
export * from './video-blacklist' export * from './video-blacklist'
export * from './video-captions' export * from './video-captions'
export * from './video-channels' export * from './video-channels'

View File

@ -1,135 +0,0 @@
import * as express from 'express'
import { body, param, query } from 'express-validator'
import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
import {
isAbuseVideoIsValid,
isVideoAbuseModerationCommentValid,
isVideoAbuseReasonValid,
isVideoAbuseStateValid,
isVideoAbusePredefinedReasonsValid,
isVideoAbusePredefinedReasonValid,
isVideoAbuseTimestampValid,
isVideoAbuseTimestampCoherent
} from '../../../helpers/custom-validators/video-abuses'
import { logger } from '../../../helpers/logger'
import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares'
import { areValidationErrors } from '../utils'
const videoAbuseReportValidator = [
param('videoId')
.custom(isIdOrUUIDValid)
.not()
.isEmpty()
.withMessage('Should have a valid videoId'),
body('reason')
.custom(isVideoAbuseReasonValid)
.withMessage('Should have a valid reason'),
body('predefinedReasons')
.optional()
.custom(isVideoAbusePredefinedReasonsValid)
.withMessage('Should have a valid list of predefined reasons'),
body('startAt')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoAbuseTimestampValid)
.withMessage('Should have valid starting time value'),
body('endAt')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoAbuseTimestampValid)
.withMessage('Should have valid ending time value')
.bail()
.custom(isVideoAbuseTimestampCoherent)
.withMessage('Should have a startAt timestamp beginning before endAt'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoAbuseReport parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
return next()
}
]
const videoAbuseGetValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
return next()
}
]
const videoAbuseUpdateValidator = [
param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'),
param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'),
body('state')
.optional()
.custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'),
body('moderationComment')
.optional()
.custom(isVideoAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
return next()
}
]
const videoAbuseListValidator = [
query('id')
.optional()
.custom(isIdValid).withMessage('Should have a valid id'),
query('predefinedReason')
.optional()
.custom(isVideoAbusePredefinedReasonValid)
.withMessage('Should have a valid predefinedReason'),
query('search')
.optional()
.custom(exists).withMessage('Should have a valid search'),
query('state')
.optional()
.custom(isVideoAbuseStateValid).withMessage('Should have a valid video abuse state'),
query('videoIs')
.optional()
.custom(isAbuseVideoIsValid).withMessage('Should have a valid "video is" attribute'),
query('searchReporter')
.optional()
.custom(exists).withMessage('Should have a valid reporter search'),
query('searchReportee')
.optional()
.custom(exists).withMessage('Should have a valid reportee search'),
query('searchVideo')
.optional()
.custom(exists).withMessage('Should have a valid video search'),
query('searchVideoChannel')
.optional()
.custom(exists).withMessage('Should have a valid video channel search'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking videoAbuseListValidator parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoAbuseListValidator,
videoAbuseReportValidator,
videoAbuseGetValidator,
videoAbuseUpdateValidator
}

View File

@ -1,5 +1,6 @@
import * as Bluebird from 'bluebird' import * as Bluebird from 'bluebird'
import { literal, Op } from 'sequelize' import { invert } from 'lodash'
import { literal, Op, WhereOptions } from 'sequelize'
import { import {
AllowNull, AllowNull,
BelongsTo, BelongsTo,
@ -8,36 +9,35 @@ import {
DataType, DataType,
Default, Default,
ForeignKey, ForeignKey,
HasOne,
Is, Is,
Model, Model,
Scopes, Scopes,
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } from 'sequelize-typescript'
import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
import { import {
VideoAbuseState, Abuse,
VideoDetails, AbuseObject,
VideoAbusePredefinedReasons, AbusePredefinedReasons,
VideoAbusePredefinedReasonsString, abusePredefinedReasonsMap,
videoAbusePredefinedReasonsMap AbusePredefinedReasonsString,
} from '../../../shared' AbuseState,
import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' AbuseVideoIs,
import { VideoAbuse } from '../../../shared/models/videos' VideoAbuse
import { } from '@shared/models'
isVideoAbuseModerationCommentValid, import { AbuseFilter } from '@shared/models/moderation/abuse/abuse-filter'
isVideoAbuseReasonValid, import { CONSTRAINTS_FIELDS, ABUSE_STATES } from '../../initializers/constants'
isVideoAbuseStateValid import { MAbuse, MAbuseAP, MAbuseFormattable, MUserAccountId } from '../../types/models'
} from '../../helpers/custom-validators/video-abuses' import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
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 { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils'
import { ThumbnailModel } from './thumbnail' import { ThumbnailModel } from '../video/thumbnail'
import { VideoModel } from './video' import { VideoModel } from '../video/video'
import { VideoBlacklistModel } from './video-blacklist' import { VideoBlacklistModel } from '../video/video-blacklist'
import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
import { invert } from 'lodash' import { VideoAbuseModel } from './video-abuse'
import { VideoCommentAbuseModel } from './video-comment-abuse'
export enum ScopeNames { export enum ScopeNames {
FOR_API = 'FOR_API' FOR_API = 'FOR_API'
@ -49,20 +49,26 @@ export enum ScopeNames {
search?: string search?: string
searchReporter?: string searchReporter?: string
searchReportee?: string searchReportee?: string
// video releated
searchVideo?: string searchVideo?: string
searchVideoChannel?: string searchVideoChannel?: string
videoIs?: AbuseVideoIs
// filters // filters
id?: number id?: number
predefinedReasonId?: number predefinedReasonId?: number
filter?: AbuseFilter
state?: VideoAbuseState state?: AbuseState
videoIs?: VideoAbuseVideoIs
// accountIds // accountIds
serverAccountId: number serverAccountId: number
userAccountId: number userAccountId: number
}) => { }) => {
const onlyBlacklisted = options.videoIs === 'blacklisted'
const videoRequired = !!(onlyBlacklisted || options.searchVideo || options.searchVideoChannel)
const where = { const where = {
reporterAccountId: { reporterAccountId: {
[Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')') [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
@ -70,33 +76,36 @@ export enum ScopeNames {
} }
if (options.search) { if (options.search) {
const escapedSearch = AbuseModel.sequelize.escape('%' + options.search + '%')
Object.assign(where, { Object.assign(where, {
[Op.or]: [ [Op.or]: [
{ {
[Op.and]: [ [Op.and]: [
{ videoId: { [Op.not]: null } }, { '$VideoAbuse.videoId$': { [Op.not]: null } },
searchAttribute(options.search, '$Video.name$') searchAttribute(options.search, '$VideoAbuse.Video.name$')
] ]
}, },
{ {
[Op.and]: [ [Op.and]: [
{ videoId: { [Op.not]: null } }, { '$VideoAbuse.videoId$': { [Op.not]: null } },
searchAttribute(options.search, '$Video.VideoChannel.name$') searchAttribute(options.search, '$VideoAbuse.Video.VideoChannel.name$')
] ]
}, },
{ {
[Op.and]: [ [Op.and]: [
{ deletedVideo: { [Op.not]: null } }, { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
{ deletedVideo: searchAttribute(options.search, 'name') } literal(`"VideoAbuse"."deletedVideo"->>'name' ILIKE ${escapedSearch}`)
] ]
}, },
{ {
[Op.and]: [ [Op.and]: [
{ deletedVideo: { [Op.not]: null } }, { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
{ deletedVideo: { channel: searchAttribute(options.search, 'displayName') } } literal(`"VideoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE ${escapedSearch}`)
] ]
}, },
searchAttribute(options.search, '$Account.name$') searchAttribute(options.search, '$ReporterAccount.name$'),
searchAttribute(options.search, '$FlaggedAccount.name$')
] ]
}) })
} }
@ -106,7 +115,7 @@ export enum ScopeNames {
if (options.videoIs === 'deleted') { if (options.videoIs === 'deleted') {
Object.assign(where, { Object.assign(where, {
deletedVideo: { '$VideoAbuse.deletedVideo$': {
[Op.not]: null [Op.not]: null
} }
}) })
@ -120,8 +129,6 @@ export enum ScopeNames {
}) })
} }
const onlyBlacklisted = options.videoIs === 'blacklisted'
return { return {
attributes: { attributes: {
include: [ include: [
@ -131,7 +138,7 @@ export enum ScopeNames {
'(' + '(' +
'SELECT count(*) ' + 'SELECT count(*) ' +
'FROM "videoAbuse" ' + 'FROM "videoAbuse" ' +
'WHERE "videoId" = "VideoAbuseModel"."videoId" ' + 'WHERE "videoId" = "VideoAbuse"."videoId" ' +
')' ')'
), ),
'countReportsForVideo' 'countReportsForVideo'
@ -146,7 +153,7 @@ export enum ScopeNames {
'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' + 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
'FROM "videoAbuse" ' + 'FROM "videoAbuse" ' +
') t ' + ') t ' +
'WHERE t.id = "VideoAbuseModel".id ' + 'WHERE t.id = "VideoAbuse".id' +
')' ')'
), ),
'nthReportForVideo' 'nthReportForVideo'
@ -159,7 +166,7 @@ export enum ScopeNames {
'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' + 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' + 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
'WHERE "account"."id" = "VideoAbuseModel"."reporterAccountId" ' + 'WHERE "account"."id" = "AbuseModel"."reporterAccountId" ' +
')' ')'
), ),
'countReportsForReporter__video' 'countReportsForReporter__video'
@ -169,7 +176,7 @@ export enum ScopeNames {
'(' + '(' +
'SELECT count(DISTINCT "videoAbuse"."id") ' + 'SELECT count(DISTINCT "videoAbuse"."id") ' +
'FROM "videoAbuse" ' + 'FROM "videoAbuse" ' +
`WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuseModel"."reporterAccountId" ` + `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "AbuseModel"."reporterAccountId" ` +
')' ')'
), ),
'countReportsForReporter__deletedVideo' 'countReportsForReporter__deletedVideo'
@ -182,8 +189,8 @@ export enum ScopeNames {
'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' + 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
'INNER JOIN "account" ON ' + 'INNER JOIN "account" ON ' +
'"videoChannel"."accountId" = "Video->VideoChannel"."accountId" ' + '"videoChannel"."accountId" = "VideoAbuse->Video->VideoChannel"."accountId" ' +
`OR "videoChannel"."accountId" = CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` + `OR "videoChannel"."accountId" = CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
')' ')'
), ),
'countReportsForReportee__video' 'countReportsForReportee__video'
@ -193,9 +200,9 @@ export enum ScopeNames {
'(' + '(' +
'SELECT count(DISTINCT "videoAbuse"."id") ' + 'SELECT count(DISTINCT "videoAbuse"."id") ' +
'FROM "videoAbuse" ' + 'FROM "videoAbuse" ' +
`WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "Video->VideoChannel"."accountId" ` + `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuse->Video->VideoChannel"."accountId" ` +
`OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` + `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` +
`CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` + `CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
')' ')'
), ),
'countReportsForReportee__deletedVideo' 'countReportsForReportee__deletedVideo'
@ -204,32 +211,47 @@ export enum ScopeNames {
}, },
include: [ include: [
{ {
model: AccountModel, model: AccountModel.scope(AccountScopeNames.SUMMARY),
as: 'ReporterAccount',
required: true, required: true,
where: searchAttribute(options.searchReporter, 'name') where: searchAttribute(options.searchReporter, 'name')
}, },
{ {
model: VideoModel, model: AccountModel.scope(AccountScopeNames.SUMMARY),
required: !!(onlyBlacklisted || options.searchVideo || options.searchReportee || options.searchVideoChannel), as: 'FlaggedAccount',
where: searchAttribute(options.searchVideo, 'name'), required: true,
where: searchAttribute(options.searchReportee, 'name')
},
{
model: VideoAbuseModel,
required: options.filter === 'video' || !!options.videoIs || videoRequired,
include: [ include: [
{ {
model: ThumbnailModel model: VideoModel,
}, required: videoRequired,
{ where: searchAttribute(options.searchVideo, 'name'),
model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
where: searchAttribute(options.searchVideoChannel, 'name'),
include: [ include: [
{ {
model: AccountModel, model: ThumbnailModel
where: searchAttribute(options.searchReportee, 'name') },
{
model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: false } as SummaryOptions ] }),
where: searchAttribute(options.searchVideoChannel, 'name'),
required: true,
include: [
{
model: AccountModel.scope(AccountScopeNames.SUMMARY),
required: true,
where: searchAttribute(options.searchReportee, 'name')
}
]
},
{
attributes: [ 'id', 'reason', 'unfederated' ],
model: VideoBlacklistModel,
required: onlyBlacklisted
} }
] ]
},
{
attributes: [ 'id', 'reason', 'unfederated' ],
model: VideoBlacklistModel,
required: onlyBlacklisted
} }
] ]
} }
@ -239,55 +261,40 @@ export enum ScopeNames {
} }
})) }))
@Table({ @Table({
tableName: 'videoAbuse', tableName: 'abuse',
indexes: [ indexes: [
{ {
fields: [ 'videoId' ] fields: [ 'reporterAccountId' ]
}, },
{ {
fields: [ 'reporterAccountId' ] fields: [ 'flaggedAccountId' ]
} }
] ]
}) })
export class VideoAbuseModel extends Model<VideoAbuseModel> { export class AbuseModel extends Model<AbuseModel> {
@AllowNull(false) @AllowNull(false)
@Default(null) @Default(null)
@Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason')) @Is('VideoAbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max)) @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
reason: string reason: string
@AllowNull(false) @AllowNull(false)
@Default(null) @Default(null)
@Is('VideoAbuseState', value => throwIfNotValid(value, isVideoAbuseStateValid, 'state')) @Is('VideoAbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
@Column @Column
state: VideoAbuseState state: AbuseState
@AllowNull(true) @AllowNull(true)
@Default(null) @Default(null)
@Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment', true)) @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
@Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max)) @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
moderationComment: string moderationComment: string
@AllowNull(true)
@Default(null)
@Column(DataType.JSONB)
deletedVideo: VideoDetails
@AllowNull(true) @AllowNull(true)
@Default(null) @Default(null)
@Column(DataType.ARRAY(DataType.INTEGER)) @Column(DataType.ARRAY(DataType.INTEGER))
predefinedReasons: VideoAbusePredefinedReasons[] predefinedReasons: AbusePredefinedReasons[]
@AllowNull(true)
@Default(null)
@Column
startAt: number
@AllowNull(true)
@Default(null)
@Column
endAt: number
@CreatedAt @CreatedAt
createdAt: Date createdAt: Date
@ -301,36 +308,65 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
@BelongsTo(() => AccountModel, { @BelongsTo(() => AccountModel, {
foreignKey: { foreignKey: {
name: 'reporterAccountId',
allowNull: true allowNull: true
}, },
as: 'ReporterAccount',
onDelete: 'set null' onDelete: 'set null'
}) })
Account: AccountModel ReporterAccount: AccountModel
@ForeignKey(() => VideoModel) @ForeignKey(() => AccountModel)
@Column @Column
videoId: number flaggedAccountId: number
@BelongsTo(() => VideoModel, { @BelongsTo(() => AccountModel, {
foreignKey: { foreignKey: {
name: 'flaggedAccountId',
allowNull: true allowNull: true
}, },
as: 'FlaggedAccount',
onDelete: 'set null' onDelete: 'set null'
}) })
Video: VideoModel FlaggedAccount: AccountModel
static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> { @HasOne(() => VideoCommentAbuseModel, {
const videoAttributes = {} foreignKey: {
if (videoId) videoAttributes['videoId'] = videoId name: 'abuseId',
if (uuid) videoAttributes['deletedVideo'] = { uuid } allowNull: false
},
onDelete: 'cascade'
})
VideoCommentAbuse: VideoCommentAbuseModel
@HasOne(() => VideoAbuseModel, {
foreignKey: {
name: 'abuseId',
allowNull: false
},
onDelete: 'cascade'
})
VideoAbuse: VideoAbuseModel
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 = { const query = {
include: [
{
model: VideoAbuseModel,
required: true,
where: videoWhere
}
],
where: { where: {
id, id
...videoAttributes
} }
} }
return VideoAbuseModel.findOne(query) return AbuseModel.findOne(query)
} }
static listForApi (parameters: { static listForApi (parameters: {
@ -338,13 +374,15 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
count: number count: number
sort: string sort: string
filter?: AbuseFilter
serverAccountId: number serverAccountId: number
user?: MUserAccountId user?: MUserAccountId
id?: number id?: number
predefinedReason?: VideoAbusePredefinedReasonsString predefinedReason?: AbusePredefinedReasonsString
state?: VideoAbuseState state?: AbuseState
videoIs?: VideoAbuseVideoIs videoIs?: AbuseVideoIs
search?: string search?: string
searchReporter?: string searchReporter?: string
@ -364,24 +402,26 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
predefinedReason, predefinedReason,
searchReportee, searchReportee,
searchVideo, searchVideo,
filter,
searchVideoChannel, searchVideoChannel,
searchReporter, searchReporter,
id id
} = parameters } = parameters
const userAccountId = user ? user.Account.id : undefined const userAccountId = user ? user.Account.id : undefined
const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
const query = { const query = {
offset: start, offset: start,
limit: count, limit: count,
order: getSort(sort), order: getSort(sort),
col: 'VideoAbuseModel.id', col: 'AbuseModel.id',
distinct: true distinct: true
} }
const filters = { const filters = {
id, id,
filter,
predefinedReasonId, predefinedReasonId,
search, search,
state, state,
@ -394,7 +434,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
userAccountId userAccountId
} }
return VideoAbuseModel return AbuseModel
.scope([ .scope([
{ method: [ ScopeNames.FOR_API, filters ] } { method: [ ScopeNames.FOR_API, filters ] }
]) ])
@ -404,8 +444,8 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
}) })
} }
toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { toFormattedJSON (this: MAbuseFormattable): Abuse {
const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
const countReportsForVideo = this.get('countReportsForVideo') as number const countReportsForVideo = this.get('countReportsForVideo') as number
const nthReportForVideo = this.get('nthReportForVideo') as number const nthReportForVideo = this.get('nthReportForVideo') as number
const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number
@ -413,51 +453,70 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number
const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number
const video = this.Video let video: VideoAbuse
? this.Video
: this.deletedVideo 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
}
}
return { return {
id: this.id, id: this.id,
reason: this.reason, reason: this.reason,
predefinedReasons, predefinedReasons,
reporterAccount: this.Account.toFormattedJSON(),
reporterAccount: this.ReporterAccount.toFormattedJSON(),
state: { state: {
id: this.state, id: this.state,
label: VideoAbuseModel.getStateLabel(this.state) label: AbuseModel.getStateLabel(this.state)
}, },
moderationComment: this.moderationComment, moderationComment: this.moderationComment,
video: {
id: video.id, video,
uuid: video.uuid, comment: null,
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, createdAt: this.createdAt,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
startAt: this.startAt,
endAt: this.endAt,
count: countReportsForVideo || 0, count: countReportsForVideo || 0,
nth: nthReportForVideo || 0, nth: nthReportForVideo || 0,
countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0), countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0) countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0),
// FIXME: deprecated in 2.3, remove this
startAt: null,
endAt: null
} }
} }
toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject { toActivityPubObject (this: MAbuseAP): AbuseObject {
const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
const startAt = this.startAt const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
const endAt = this.endAt
const startAt = this.VideoAbuse?.startAt
const endAt = this.VideoAbuse?.endAt
return { return {
type: 'Flag' as 'Flag', type: 'Flag' as 'Flag',
content: this.reason, content: this.reason,
object: this.Video.url, object,
tag: predefinedReasons.map(r => ({ tag: predefinedReasons.map(r => ({
type: 'Hashtag' as 'Hashtag', type: 'Hashtag' as 'Hashtag',
name: r name: r
@ -468,12 +527,12 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
} }
private static getStateLabel (id: number) { private static getStateLabel (id: number) {
return VIDEO_ABUSE_STATES[id] || 'Unknown' return ABUSE_STATES[id] || 'Unknown'
} }
private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] { private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
return (predefinedReasons || []) return (predefinedReasons || [])
.filter(r => r in VideoAbusePredefinedReasons) .filter(r => r in AbusePredefinedReasons)
.map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString) .map(r => invert(abusePredefinedReasonsMap)[r] as AbusePredefinedReasonsString)
} }
} }

View File

@ -0,0 +1,63 @@
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { VideoDetails } from '@shared/models'
import { VideoModel } from '../video/video'
import { AbuseModel } from './abuse'
@Table({
tableName: 'videoAbuse',
indexes: [
{
fields: [ 'abuseId' ]
},
{
fields: [ 'videoId' ]
}
]
})
export class VideoAbuseModel extends Model<VideoAbuseModel> {
@CreatedAt
createdAt: Date
@UpdatedAt
updatedAt: Date
@AllowNull(true)
@Default(null)
@Column
startAt: number
@AllowNull(true)
@Default(null)
@Column
endAt: number
@AllowNull(true)
@Default(null)
@Column(DataType.JSONB)
deletedVideo: VideoDetails
@ForeignKey(() => AbuseModel)
@Column
abuseId: number
@BelongsTo(() => AbuseModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade'
})
Abuse: AbuseModel
@ForeignKey(() => VideoModel)
@Column
videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: true
},
onDelete: 'set null'
})
Video: VideoModel
}

View File

@ -0,0 +1,53 @@
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
import { VideoComment } from '@shared/models'
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
@AllowNull(true)
@Default(null)
@Column(DataType.JSONB)
deletedComment: VideoComment
@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
}

View File

@ -1,12 +1,12 @@
import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
import { AccountModel } from './account'
import { getSort, searchAttribute } from '../utils'
import { AccountBlock } from '../../../shared/models/blocklist'
import { Op } from 'sequelize'
import * as Bluebird from 'bluebird' import * 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 { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
import { AccountBlock } from '../../../shared/models'
import { ActorModel } from '../activitypub/actor' import { ActorModel } from '../activitypub/actor'
import { ServerModel } from '../server/server' import { ServerModel } from '../server/server'
import { getSort, searchAttribute } from '../utils'
import { AccountModel } from './account'
enum ScopeNames { enum ScopeNames {
WITH_ACCOUNTS = 'WITH_ACCOUNTS' WITH_ACCOUNTS = 'WITH_ACCOUNTS'

View File

@ -388,6 +388,10 @@ export class AccountModel extends Model<AccountModel> {
.findAll(query) .findAll(query)
} }
getClientUrl () {
return WEBSERVER.URL + '/accounts/' + this.Actor.getIdentifier()
}
toFormattedJSON (this: MAccountFormattable): Account { toFormattedJSON (this: MAccountFormattable): Account {
const actor = this.Actor.toFormattedJSON() const actor = this.Actor.toFormattedJSON()
const account = { const account = {

View File

@ -1,22 +1,24 @@
import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' import { 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 { UserNotification, UserNotificationType } from '../../../shared'
import { getSort, throwIfNotValid } from '../utils'
import { isBooleanValid } from '../../helpers/custom-validators/misc' import { isBooleanValid } from '../../helpers/custom-validators/misc'
import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
import { UserModel } from './user' import { AbuseModel } from '../abuse/abuse'
import { VideoModel } from '../video/video' import { VideoAbuseModel } from '../abuse/video-abuse'
import { VideoCommentModel } from '../video/video-comment' import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
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 { ActorModel } from '../activitypub/actor' import { ActorModel } from '../activitypub/actor'
import { ActorFollowModel } from '../activitypub/actor-follow' import { ActorFollowModel } from '../activitypub/actor-follow'
import { AvatarModel } from '../avatar/avatar' import { AvatarModel } from '../avatar/avatar'
import { ServerModel } from '../server/server' 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 { enum ScopeNames {
WITH_ALL = 'WITH_ALL' WITH_ALL = 'WITH_ALL'
@ -87,9 +89,41 @@ function buildAccountInclude (required: boolean, withActor = false) {
{ {
attributes: [ 'id' ], attributes: [ 'id' ],
model: VideoAbuseModel.unscoped(), model: AbuseModel.unscoped(),
required: false, 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: [ 'uuid' ],
model: VideoModel.unscoped(),
required: true
}
]
}
]
},
{
model: AccountModel,
as: 'FlaggedAccount',
required: true,
include: [ buildActorWithAvatarInclude() ]
}
]
}, },
{ {
@ -179,9 +213,9 @@ function buildAccountInclude (required: boolean, withActor = false) {
} }
}, },
{ {
fields: [ 'videoAbuseId' ], fields: [ 'abuseId' ],
where: { where: {
videoAbuseId: { abuseId: {
[Op.ne]: null [Op.ne]: null
} }
} }
@ -276,17 +310,17 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
}) })
Comment: VideoCommentModel Comment: VideoCommentModel
@ForeignKey(() => VideoAbuseModel) @ForeignKey(() => AbuseModel)
@Column @Column
videoAbuseId: number abuseId: number
@BelongsTo(() => VideoAbuseModel, { @BelongsTo(() => AbuseModel, {
foreignKey: { foreignKey: {
allowNull: true allowNull: true
}, },
onDelete: 'cascade' onDelete: 'cascade'
}) })
VideoAbuse: VideoAbuseModel Abuse: AbuseModel
@ForeignKey(() => VideoBlacklistModel) @ForeignKey(() => VideoBlacklistModel)
@Column @Column
@ -397,10 +431,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
video: this.formatVideo(this.Comment.Video) video: this.formatVideo(this.Comment.Video)
} : undefined } : undefined
const videoAbuse = this.VideoAbuse ? { const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
id: this.VideoAbuse.id,
video: this.formatVideo(this.VideoAbuse.Video)
} : undefined
const videoBlacklist = this.VideoBlacklist ? { const videoBlacklist = this.VideoBlacklist ? {
id: this.VideoBlacklist.id, id: this.VideoBlacklist.id,
@ -439,7 +470,7 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
video, video,
videoImport, videoImport,
comment, comment,
videoAbuse, abuse,
videoBlacklist, videoBlacklist,
account, account,
actorFollow, actorFollow,
@ -456,6 +487,27 @@ export class UserNotificationModel extends Model<UserNotificationModel> {
} }
} }
formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
const commentAbuse = abuse.VideoCommentAbuse?.VideoComment ? {
threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
video: {
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 ( formatActor (
this: UserNotificationModelForApi, this: UserNotificationModelForApi,
accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor

View File

@ -19,7 +19,7 @@ import {
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } 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 { User, UserRole } from '../../../shared/models/users'
import { import {
isNoInstanceConfigWarningModal, isNoInstanceConfigWarningModal,
@ -169,7 +169,7 @@ enum ScopeNames {
`SELECT concat_ws(':', "abuses", "acceptedAbuses") ` + `SELECT concat_ws(':', "abuses", "acceptedAbuses") ` +
'FROM (' + 'FROM (' +
'SELECT COUNT("videoAbuse"."id") AS "abuses", ' + 'SELECT COUNT("videoAbuse"."id") AS "abuses", ' +
`COUNT("videoAbuse"."id") FILTER (WHERE "videoAbuse"."state" = ${VideoAbuseState.ACCEPTED}) AS "acceptedAbuses" ` + `COUNT("videoAbuse"."id") FILTER (WHERE "videoAbuse"."state" = ${AbuseState.ACCEPTED}) AS "acceptedAbuses" ` +
'FROM "videoAbuse" ' + 'FROM "videoAbuse" ' +
'INNER JOIN "video" ON "videoAbuse"."videoId" = "video"."id" ' + 'INNER JOIN "video" ON "videoAbuse"."videoId" = "video"."id" ' +
'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' + 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +

View File

@ -1,11 +1,11 @@
import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
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 * as Bluebird from 'bluebird'
import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
import { Op } from 'sequelize' 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 { getSort, searchAttribute } from '../utils'
import { ServerModel } from './server'
enum ScopeNames { enum ScopeNames {
WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACCOUNT = 'WITH_ACCOUNT',

View File

@ -1,4 +1,5 @@
import * as Bluebird from 'bluebird' import * as Bluebird from 'bluebird'
import { remove } from 'fs-extra'
import { maxBy, minBy, pick } from 'lodash' import { maxBy, minBy, pick } from 'lodash'
import { join } from 'path' import { join } from 'path'
import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' import { FindOptions, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
@ -23,10 +24,18 @@ import {
Table, Table,
UpdatedAt UpdatedAt
} from 'sequelize-typescript' } 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 { VideoTorrentObject } from '../../../shared/models/activitypub/objects'
import { Video, VideoDetails } from '../../../shared/models/videos' 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 { 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 { peertubeTruncate } from '../../helpers/core-utils'
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
import { isBooleanValid } from '../../helpers/custom-validators/misc' import { isBooleanValid } from '../../helpers/custom-validators/misc'
@ -43,6 +52,7 @@ import {
} from '../../helpers/custom-validators/videos' } from '../../helpers/custom-validators/videos'
import { getVideoFileResolution } from '../../helpers/ffmpeg-utils' import { getVideoFileResolution } from '../../helpers/ffmpeg-utils'
import { logger } from '../../helpers/logger' import { logger } from '../../helpers/logger'
import { CONFIG } from '../../initializers/config'
import { import {
ACTIVITY_PUB, ACTIVITY_PUB,
API_VERSION, API_VERSION,
@ -59,40 +69,6 @@ import {
WEBSERVER WEBSERVER
} from '../../initializers/constants' } from '../../initializers/constants'
import { sendDeleteVideo } from '../../lib/activitypub/send' 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 { import {
MChannel, MChannel,
MChannelAccountDefault, MChannelAccountDefault,
@ -118,15 +94,39 @@ import {
MVideoWithFile, MVideoWithFile,
MVideoWithRights MVideoWithRights
} from '../../types/models' } from '../../types/models'
import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
import { MThumbnail } from '../../types/models/video/thumbnail' import { MThumbnail } from '../../types/models/video/thumbnail'
import { VideoFile } from '@shared/models/videos/video-file.model' import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
import { getHLSDirectory, getTorrentFileName, getTorrentFilePath, getVideoFilename, getVideoFilePath } from '@server/lib/video-paths' import { VideoAbuseModel } from '../abuse/video-abuse'
import { ModelCache } from '@server/models/model-cache' 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 { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder'
import { buildNSFWFilter } from '@server/helpers/express-utils' import { VideoShareModel } from './video-share'
import { getServerActor } from '@server/models/application/application' import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
import { getPrivaciesForFederation, isPrivacyForFederation } from "@server/helpers/video" import { VideoTagModel } from './video-tag'
import { VideoViewModel } from './video-view'
export enum ScopeNames { export enum ScopeNames {
AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS', AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import 'mocha' import 'mocha'
import { AbuseState, VideoAbuseCreate } from '@shared/models'
import { import {
cleanupTests, cleanupTests,
createUser, createUser,
@ -20,7 +20,8 @@ import {
checkBadSortPagination, checkBadSortPagination,
checkBadStartPagination checkBadStartPagination
} from '../../../../shared/extra-utils/requests/check-api-params' } 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 () { describe('Test video abuses API validators', function () {
let server: ServerInfo let server: ServerInfo
@ -136,7 +137,7 @@ describe('Test video abuses API validators', function () {
const fields = { reason: 'my super reason' } const fields = { reason: 'my super reason' }
const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) 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 () { it('Should fail with a wrong predefined reason', async function () {
@ -190,7 +191,7 @@ describe('Test video abuses API validators', function () {
}) })
it('Should succeed with the correct params', async function () { it('Should succeed with the correct params', async function () {
const body = { state: VideoAbuseState.ACCEPTED } const body = { state: AbuseState.ACCEPTED }
await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body) await updateVideoAbuse(server.url, server.accessToken, server.video.uuid, videoAbuseId, body)
}) })
}) })

View File

@ -2,7 +2,7 @@
import * as chai from 'chai' import * as chai from 'chai'
import 'mocha' import 'mocha'
import { MyUser, User, UserRole, Video, VideoAbuseState, VideoAbuseUpdate, VideoPlaylistType } from '../../../../shared/index' import { MyUser, User, UserRole, Video, AbuseState, AbuseUpdate, VideoPlaylistType } from '@shared/models'
import { import {
addVideoCommentThread, addVideoCommentThread,
blockUser, blockUser,
@ -937,7 +937,7 @@ describe('Test users', function () {
expect(user2.videoAbusesCount).to.equal(1) // number of incriminations expect(user2.videoAbusesCount).to.equal(1) // number of incriminations
expect(user2.videoAbusesCreatedCount).to.equal(1) // number of reports created expect(user2.videoAbusesCreatedCount).to.equal(1) // number of reports created
const body: VideoAbuseUpdate = { state: VideoAbuseState.ACCEPTED } const body: AbuseUpdate = { state: AbuseState.ACCEPTED }
await updateVideoAbuse(server.url, server.accessToken, videoId, abuseId, body) await updateVideoAbuse(server.url, server.accessToken, videoId, abuseId, body)
const res3 = await getUserInformation(server.url, server.accessToken, user17Id, true) const res3 = await getUserInformation(server.url, server.accessToken, user17Id, true)

View File

@ -1,21 +1,21 @@
/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
import * as chai from 'chai'
import 'mocha' import 'mocha'
import { VideoAbuse, VideoAbuseState, VideoAbusePredefinedReasonsString } from '../../../../shared/models/videos' import * as chai from 'chai'
import { Abuse, AbusePredefinedReasonsString, AbuseState } from '@shared/models'
import { import {
cleanupTests, cleanupTests,
createUser,
deleteVideoAbuse, deleteVideoAbuse,
flushAndRunMultipleServers, flushAndRunMultipleServers,
getVideoAbusesList, getVideoAbusesList,
getVideosList, getVideosList,
removeVideo,
reportVideoAbuse, reportVideoAbuse,
ServerInfo, ServerInfo,
setAccessTokensToServers, setAccessTokensToServers,
updateVideoAbuse, updateVideoAbuse,
uploadVideo, uploadVideo,
removeVideo,
createUser,
userLogin userLogin
} from '../../../../shared/extra-utils/index' } from '../../../../shared/extra-utils/index'
import { doubleFollow } from '../../../../shared/extra-utils/server/follows' import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
@ -29,9 +29,11 @@ import {
const expect = chai.expect const expect = chai.expect
// FIXME: deprecated in 2.3. Remove this controller
describe('Test video abuses', function () { describe('Test video abuses', function () {
let servers: ServerInfo[] = [] let servers: ServerInfo[] = []
let abuseServer2: VideoAbuse let abuseServer2: Abuse
before(async function () { before(async function () {
this.timeout(50000) this.timeout(50000)
@ -95,7 +97,7 @@ describe('Test video abuses', function () {
expect(res1.body.data).to.be.an('array') expect(res1.body.data).to.be.an('array')
expect(res1.body.data.length).to.equal(1) 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.reason).to.equal('my super bad reason')
expect(abuse.reporterAccount.name).to.equal('root') expect(abuse.reporterAccount.name).to.equal('root')
expect(abuse.reporterAccount.host).to.equal('localhost:' + servers[0].port) expect(abuse.reporterAccount.host).to.equal('localhost:' + servers[0].port)
@ -128,23 +130,23 @@ describe('Test video abuses', function () {
expect(res1.body.data).to.be.an('array') expect(res1.body.data).to.be.an('array')
expect(res1.body.data.length).to.equal(2) 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.reason).to.equal('my super bad reason')
expect(abuse1.reporterAccount.name).to.equal('root') expect(abuse1.reporterAccount.name).to.equal('root')
expect(abuse1.reporterAccount.host).to.equal('localhost:' + servers[0].port) expect(abuse1.reporterAccount.host).to.equal('localhost:' + servers[0].port)
expect(abuse1.video.id).to.equal(servers[0].video.id) 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.state.label).to.equal('Pending')
expect(abuse1.moderationComment).to.be.null expect(abuse1.moderationComment).to.be.null
expect(abuse1.count).to.equal(1) expect(abuse1.count).to.equal(1)
expect(abuse1.nth).to.equal(1) expect(abuse1.nth).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.reason).to.equal('my super bad reason 2')
expect(abuse2.reporterAccount.name).to.equal('root') expect(abuse2.reporterAccount.name).to.equal('root')
expect(abuse2.reporterAccount.host).to.equal('localhost:' + servers[0].port) expect(abuse2.reporterAccount.host).to.equal('localhost:' + servers[0].port)
expect(abuse2.video.id).to.equal(servers[1].video.id) 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.state.label).to.equal('Pending')
expect(abuse2.moderationComment).to.be.null expect(abuse2.moderationComment).to.be.null
@ -157,25 +159,25 @@ describe('Test video abuses', function () {
expect(abuseServer2.reason).to.equal('my super bad reason 2') expect(abuseServer2.reason).to.equal('my super bad reason 2')
expect(abuseServer2.reporterAccount.name).to.equal('root') expect(abuseServer2.reporterAccount.name).to.equal('root')
expect(abuseServer2.reporterAccount.host).to.equal('localhost:' + servers[0].port) 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.state.label).to.equal('Pending')
expect(abuseServer2.moderationComment).to.be.null expect(abuseServer2.moderationComment).to.be.null
}) })
it('Should update the state of a video abuse', async function () { 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) 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 }) 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 () { 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) 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 }) 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[0].moderationComment).to.equal('It is valid')
}) })
@ -243,7 +245,7 @@ describe('Test video abuses', function () {
expect(res.body.data.length).to.equal(2, "wrong number of videos returned") expect(res.body.data.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") 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.id).to.equal(abuseServer2.video.id, "wrong video id")
expect(abuse.video.channel).to.exist expect(abuse.video.channel).to.exist
expect(abuse.video.deleted).to.be.true expect(abuse.video.deleted).to.be.true
@ -277,7 +279,7 @@ describe('Test video abuses', function () {
const res2 = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) 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) { if (abuse.video.id === video3.id) {
expect(abuse.count).to.equal(1, "wrong reports count for video 3") 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.nth).to.equal(1, "wrong report position in report list for video 3")
@ -295,7 +297,7 @@ describe('Test video abuses', function () {
this.timeout(10000) this.timeout(10000)
const reason5 = 'my super bad reason 5' const reason5 = 'my super bad reason 5'
const predefinedReasons5: VideoAbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ] const predefinedReasons5: AbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ]
const createdAbuse = (await reportVideoAbuse( const createdAbuse = (await reportVideoAbuse(
servers[0].url, servers[0].url,
servers[0].accessToken, servers[0].accessToken,
@ -304,16 +306,16 @@ describe('Test video abuses', function () {
predefinedReasons5, predefinedReasons5,
1, 1,
5 5
)).body.videoAbuse as VideoAbuse )).body.abuse
const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) 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.reason).to.equals(reason5)
expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported") 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.video.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.endAt).to.equal(5, "ending timestamp doesn't match the one reported")
} }
}) })
@ -348,7 +350,7 @@ describe('Test video abuses', function () {
const res = await getVideoAbusesList(options) 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({ id: 56 })).to.have.lengthOf(0)
@ -365,14 +367,14 @@ describe('Test video abuses', function () {
expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1) expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1)
expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5) 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({ searchReportee: 'aaaa' })).to.have.lengthOf(0)
expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1) expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1)
expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0) expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0)
expect(await list({ state: VideoAbuseState.ACCEPTED })).to.have.lengthOf(0) expect(await list({ state: AbuseState.ACCEPTED })).to.have.lengthOf(0)
expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(6) expect(await list({ state: AbuseState.PENDING })).to.have.lengthOf(6)
expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1) expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1)
expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0) expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0)

View File

@ -1,4 +1,5 @@
export * from './account' export * from './account'
export * from './moderation'
export * from './oauth' export * from './oauth'
export * from './server' export * from './server'
export * from './user' export * from './user'

View File

@ -0,0 +1,97 @@
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 } 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 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<'VideoAbuse', MVideoAbuseFormattable>

View File

@ -0,0 +1 @@
export * from './abuse'

View File

@ -1,16 +1,18 @@
import { UserNotificationModel } from '../../../models/account/user-notification' import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
import { PickWith, PickWithOpt } from '@shared/core-utils' import { PickWith, PickWithOpt } from '@shared/core-utils'
import { VideoModel } from '../../../models/video/video' import { AbuseModel } from '../../../models/abuse/abuse'
import { ActorModel } from '../../../models/activitypub/actor'
import { ServerModel } from '../../../models/server/server'
import { AvatarModel } from '../../../models/avatar/avatar'
import { VideoChannelModel } from '../../../models/video/video-channel'
import { AccountModel } from '../../../models/account/account' import { AccountModel } from '../../../models/account/account'
import { VideoCommentModel } from '../../../models/video/video-comment' import { UserNotificationModel } from '../../../models/account/user-notification'
import { VideoAbuseModel } from '../../../models/video/video-abuse' import { ActorModel } from '../../../models/activitypub/actor'
import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
import { VideoImportModel } from '../../../models/video/video-import'
import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 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 { VideoCommentModel } from '../../../models/video/video-comment'
import { VideoImportModel } from '../../../models/video/video-import'
type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationModel, K, M> type Use<K extends keyof UserNotificationModel, M> = PickWith<UserNotificationModel, K, M>
@ -47,6 +49,18 @@ export module UserNotificationIncludes {
Pick<VideoAbuseModel, 'id'> & Pick<VideoAbuseModel, 'id'> &
PickWith<VideoAbuseModel, 'Video', VideoInclude> PickWith<VideoAbuseModel, 'Video', VideoInclude>
export type VideoCommentAbuseInclude =
Pick<VideoCommentAbuseModel, 'id'> &
PickWith<VideoCommentAbuseModel, 'VideoComment',
Pick<VideoCommentModel, 'id' | 'originCommentId' | 'getThreadId'> &
PickWith<VideoCommentModel, 'Video', Pick<VideoModel, 'uuid'>>>
export type AbuseInclude =
Pick<AbuseModel, 'id'> &
PickWith<AbuseModel, 'VideoAbuse', VideoAbuseInclude> &
PickWith<AbuseModel, 'VideoCommentAbuse', VideoCommentAbuseInclude> &
PickWith<AbuseModel, 'FlaggedAccount', AccountIncludeActor>
export type VideoBlacklistInclude = export type VideoBlacklistInclude =
Pick<VideoBlacklistModel, 'id'> & Pick<VideoBlacklistModel, 'id'> &
PickWith<VideoAbuseModel, 'Video', VideoInclude> PickWith<VideoAbuseModel, 'Video', VideoInclude>
@ -76,7 +90,7 @@ export module UserNotificationIncludes {
// ############################################################################ // ############################################################################
export type MUserNotification = export type MUserNotification =
Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'VideoAbuse' | 'VideoBlacklist' | Omit<UserNotificationModel, 'User' | 'Video' | 'Comment' | 'Abuse' | 'VideoBlacklist' |
'VideoImport' | 'Account' | 'ActorFollow'> 'VideoImport' | 'Account' | 'ActorFollow'>
// ############################################################################ // ############################################################################
@ -85,7 +99,7 @@ export type UserNotificationModelForApi =
MUserNotification & MUserNotification &
Use<'Video', UserNotificationIncludes.VideoIncludeChannel> & Use<'Video', UserNotificationIncludes.VideoIncludeChannel> &
Use<'Comment', UserNotificationIncludes.VideoCommentInclude> & Use<'Comment', UserNotificationIncludes.VideoCommentInclude> &
Use<'VideoAbuse', UserNotificationIncludes.VideoAbuseInclude> & Use<'Abuse', UserNotificationIncludes.AbuseInclude> &
Use<'VideoBlacklist', UserNotificationIncludes.VideoBlacklistInclude> & Use<'VideoBlacklist', UserNotificationIncludes.VideoBlacklistInclude> &
Use<'VideoImport', UserNotificationIncludes.VideoImportInclude> & Use<'VideoImport', UserNotificationIncludes.VideoImportInclude> &
Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> & Use<'ActorFollow', UserNotificationIncludes.ActorFollowInclude> &

View File

@ -2,7 +2,6 @@ export * from './schedule-video-update'
export * from './tag' export * from './tag'
export * from './thumbnail' export * from './thumbnail'
export * from './video' export * from './video'
export * from './video-abuse'
export * from './video-blacklist' export * from './video-blacklist'
export * from './video-caption' export * from './video-caption'
export * from './video-change-ownership' export * from './video-change-ownership'

View File

@ -1,35 +0,0 @@
import { VideoAbuseModel } from '../../../models/video/video-abuse'
import { PickWith } from '@shared/core-utils'
import { MVideoAccountLightBlacklistAllFiles, MVideo } from './video'
import { MAccountDefault, MAccountFormattable } from '../account'
type Use<K extends keyof VideoAbuseModel, M> = PickWith<VideoAbuseModel, K, M>
// ############################################################################
export type MVideoAbuse = Omit<VideoAbuseModel, 'Account' | 'Video' | 'toActivityPubObject'>
// ############################################################################
export type MVideoAbuseId = Pick<VideoAbuseModel, 'id'>
export type MVideoAbuseVideo =
MVideoAbuse &
Pick<VideoAbuseModel, 'toActivityPubObject'> &
Use<'Video', MVideo>
export type MVideoAbuseAccountVideo =
MVideoAbuse &
Pick<VideoAbuseModel, 'toActivityPubObject'> &
Use<'Video', MVideoAccountLightBlacklistAllFiles> &
Use<'Account', MAccountDefault>
// ############################################################################
// Format for API or AP object
export type MVideoAbuseFormattable =
MVideoAbuse &
Use<'Account', MAccountFormattable> &
Use<'Video', Pick<MVideoAccountLightBlacklistAllFiles,
'id' | 'uuid' | 'name' | 'nsfw' | 'getMiniatureStaticPath' | 'isBlacklisted' | 'VideoChannel'>>

View File

@ -1,5 +1,6 @@
import { RegisterServerAuthExternalOptions } from '@server/types' import { RegisterServerAuthExternalOptions } from '@server/types'
import { import {
MAbuse,
MAccountBlocklist, MAccountBlocklist,
MActorUrl, MActorUrl,
MStreamingPlaylist, MStreamingPlaylist,
@ -26,7 +27,6 @@ import {
MComment, MComment,
MCommentOwnerVideoReply, MCommentOwnerVideoReply,
MUserDefault, MUserDefault,
MVideoAbuse,
MVideoBlacklist, MVideoBlacklist,
MVideoCaptionVideo, MVideoCaptionVideo,
MVideoFullLight, MVideoFullLight,
@ -77,7 +77,7 @@ declare module 'express' {
videoCaption?: MVideoCaptionVideo videoCaption?: MVideoCaptionVideo
videoAbuse?: MVideoAbuse abuse?: MAbuse
videoStreamingPlaylist?: MStreamingPlaylist videoStreamingPlaylist?: MStreamingPlaylist

View File

@ -17,6 +17,7 @@ export * from './videos/services'
export * from './videos/video-playlists' export * from './videos/video-playlists'
export * from './users/users' export * from './users/users'
export * from './users/accounts' export * from './users/accounts'
export * from './moderation/abuses'
export * from './videos/video-abuses' export * from './videos/video-abuses'
export * from './videos/video-blacklist' export * from './videos/video-blacklist'
export * from './videos/video-captions' export * from './videos/video-captions'

View File

@ -0,0 +1,112 @@
import * as request from 'supertest'
import { AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models'
import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests'
function reportAbuse (
url: string,
token: string,
videoId: number | string,
reason: string,
predefinedReasons?: AbusePredefinedReasonsString[],
startAt?: number,
endAt?: number,
specialStatus = 200
) {
const path = '/api/v1/videos/' + videoId + '/abuse'
return request(url)
.post(path)
.set('Accept', 'application/json')
.set('Authorization', 'Bearer ' + token)
.send({ reason, predefinedReasons, startAt, endAt })
.expect(specialStatus)
}
function getAbusesList (options: {
url: string
token: string
id?: number
predefinedReason?: AbusePredefinedReasonsString
search?: string
state?: AbuseState
videoIs?: AbuseVideoIs
searchReporter?: string
searchReportee?: string
searchVideo?: string
searchVideoChannel?: string
}) {
const {
url,
token,
id,
predefinedReason,
search,
state,
videoIs,
searchReporter,
searchReportee,
searchVideo,
searchVideoChannel
} = options
const path = '/api/v1/videos/abuse'
const query = {
sort: 'createdAt',
id,
predefinedReason,
search,
state,
videoIs,
searchReporter,
searchReportee,
searchVideo,
searchVideoChannel
}
return makeGetRequest({
url,
path,
token,
query,
statusCodeExpected: 200
})
}
function updateAbuse (
url: string,
token: string,
videoId: string | number,
videoAbuseId: number,
body: AbuseUpdate,
statusCodeExpected = 204
) {
const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId
return makePutBodyRequest({
url,
token,
path,
fields: body,
statusCodeExpected
})
}
function deleteAbuse (url: string, token: string, videoId: string | number, videoAbuseId: number, statusCodeExpected = 204) {
const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId
return makeDeleteRequest({
url,
token,
path,
statusCodeExpected
})
}
// ---------------------------------------------------------------------------
export {
reportAbuse,
getAbusesList,
updateAbuse,
deleteAbuse
}

View File

@ -443,11 +443,11 @@ async function checkNewVideoAbuseForModerators (base: CheckerBaseParams, videoUU
expect(notification).to.not.be.undefined expect(notification).to.not.be.undefined
expect(notification.type).to.equal(notificationType) expect(notification.type).to.equal(notificationType)
expect(notification.videoAbuse.id).to.be.a('number') expect(notification.abuse.id).to.be.a('number')
checkVideo(notification.videoAbuse.video, videoName, videoUUID) checkVideo(notification.abuse.video, videoName, videoUUID)
} else { } else {
expect(notification).to.satisfy((n: UserNotification) => { 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.video.uuid !== videoUUID
}) })
} }
} }

View File

@ -1,15 +1,15 @@
import * as request from 'supertest' import * as request from 'supertest'
import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model' import { AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models'
import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests' import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests'
import { VideoAbuseState, VideoAbusePredefinedReasonsString } from '@shared/models'
import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' // FIXME: deprecated in 2.3. Remove this file
function reportVideoAbuse ( function reportVideoAbuse (
url: string, url: string,
token: string, token: string,
videoId: number | string, videoId: number | string,
reason: string, reason: string,
predefinedReasons?: VideoAbusePredefinedReasonsString[], predefinedReasons?: AbusePredefinedReasonsString[],
startAt?: number, startAt?: number,
endAt?: number, endAt?: number,
specialStatus = 200 specialStatus = 200
@ -28,10 +28,10 @@ function getVideoAbusesList (options: {
url: string url: string
token: string token: string
id?: number id?: number
predefinedReason?: VideoAbusePredefinedReasonsString predefinedReason?: AbusePredefinedReasonsString
search?: string search?: string
state?: VideoAbuseState state?: AbuseState
videoIs?: VideoAbuseVideoIs videoIs?: AbuseVideoIs
searchReporter?: string searchReporter?: string
searchReportee?: string searchReportee?: string
searchVideo?: string searchVideo?: string
@ -79,7 +79,7 @@ function updateVideoAbuse (
token: string, token: string,
videoId: string | number, videoId: string | number,
videoAbuseId: number, videoAbuseId: number,
body: VideoAbuseUpdate, body: AbuseUpdate,
statusCodeExpected = 204 statusCodeExpected = 204
) { ) {
const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId

View File

@ -1,12 +1,12 @@
import { ActivityPubActor } from './activitypub-actor' import { ActivityPubActor } from './activitypub-actor'
import { ActivityPubSignature } from './activitypub-signature' 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 { 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 { APObject } from './objects/object.model'
import { PlaylistObject } from './objects/playlist-object' import { PlaylistObject } from './objects/playlist-object'
import { VideoCommentObject } from './objects/video-comment-object'
import { ViewObject } from './objects/view-object'
export type Activity = export type Activity =
ActivityCreate | ActivityCreate |
@ -53,7 +53,7 @@ export interface BaseActivity {
export interface ActivityCreate extends BaseActivity { export interface ActivityCreate extends BaseActivity {
type: 'Create' type: 'Create'
object: VideoTorrentObject | VideoAbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject object: VideoTorrentObject | AbuseObject | ViewObject | DislikeObject | VideoCommentObject | CacheFileObject | PlaylistObject
} }
export interface ActivityUpdate extends BaseActivity { export interface ActivityUpdate extends BaseActivity {

View File

@ -1,10 +1,12 @@
import { ActivityFlagReasonObject } from './common-objects' import { ActivityFlagReasonObject } from './common-objects'
export interface VideoAbuseObject { export interface AbuseObject {
type: 'Flag' type: 'Flag'
content: string content: string
object: string | string[] object: string | string[]
tag?: ActivityFlagReasonObject[] tag?: ActivityFlagReasonObject[]
startAt?: number startAt?: number
endAt?: number endAt?: number
} }

View File

@ -1,4 +1,4 @@
import { VideoAbusePredefinedReasonsString } from '@shared/models/videos' import { AbusePredefinedReasonsString } from '@shared/models'
export interface ActivityIdentifierObject { export interface ActivityIdentifierObject {
identifier: string identifier: string
@ -85,7 +85,7 @@ export interface ActivityMentionObject {
export interface ActivityFlagReasonObject { export interface ActivityFlagReasonObject {
type: 'Hashtag' type: 'Hashtag'
name: VideoAbusePredefinedReasonsString name: AbusePredefinedReasonsString
} }
export type ActivityTagObject = export type ActivityTagObject =

View File

@ -1,6 +1,6 @@
export * from './abuse-object'
export * from './cache-file-object' export * from './cache-file-object'
export * from './common-objects' export * from './common-objects'
export * from './video-abuse-object' export * from './dislike-object'
export * from './video-torrent-object' export * from './video-torrent-object'
export * from './view-object' export * from './view-object'
export * from './dislike-object'

View File

@ -1,7 +1,7 @@
export * from './activitypub' export * from './activitypub'
export * from './actors' export * from './actors'
export * from './avatars' export * from './avatars'
export * from './blocklist' export * from './moderation'
export * from './bulk' export * from './bulk'
export * from './redundancy' export * from './redundancy'
export * from './users' export * from './users'
@ -14,4 +14,3 @@ export * from './search'
export * from './server' export * from './server'
export * from './oauth-client-local.model' export * from './oauth-client-local.model'
export * from './result-list.model' export * from './result-list.model'
export * from './server/server-config.model'

View File

@ -0,0 +1,26 @@
import { AbusePredefinedReasonsString } from './abuse-reason.model'
export interface AbuseCreate {
accountId: number
reason: string
predefinedReasons?: AbusePredefinedReasonsString[]
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
}

View File

@ -0,0 +1 @@
export type AbuseFilter = 'video' | 'comment'

View File

@ -0,0 +1,33 @@
export enum AbusePredefinedReasons {
VIOLENT_OR_REPULSIVE = 1,
HATEFUL_OR_ABUSIVE,
SPAM_OR_MISLEADING,
PRIVACY,
RIGHTS,
SERVER_RULES,
THUMBNAILS,
CAPTIONS
}
export type AbusePredefinedReasonsString =
'violentOrRepulsive' |
'hatefulOrAbusive' |
'spamOrMisleading' |
'privacy' |
'rights' |
'serverRules' |
'thumbnails' |
'captions'
export const abusePredefinedReasonsMap: {
[key in AbusePredefinedReasonsString]: AbusePredefinedReasons
} = {
violentOrRepulsive: AbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
hatefulOrAbusive: AbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
spamOrMisleading: AbusePredefinedReasons.SPAM_OR_MISLEADING,
privacy: AbusePredefinedReasons.PRIVACY,
rights: AbusePredefinedReasons.RIGHTS,
serverRules: AbusePredefinedReasons.SERVER_RULES,
thumbnails: AbusePredefinedReasons.THUMBNAILS,
captions: AbusePredefinedReasons.CAPTIONS
}

View File

@ -1,4 +1,4 @@
export enum VideoAbuseState { export enum AbuseState {
PENDING = 1, PENDING = 1,
REJECTED = 2, REJECTED = 2,
ACCEPTED = 3 ACCEPTED = 3

View File

@ -0,0 +1,7 @@
import { AbuseState } from './abuse-state.model'
export interface AbuseUpdate {
moderationComment?: string
state?: AbuseState
}

View File

@ -0,0 +1 @@
export type AbuseVideoIs = 'deleted' | 'blacklisted'

View File

@ -0,0 +1,53 @@
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
}
export interface VideoCommentAbuse {
id: number
account?: Account
text: string
deleted: boolean
}
export interface Abuse {
id: number
reason: string
predefinedReasons?: AbusePredefinedReasonsString[]
reporterAccount: Account
state: VideoConstant<AbuseState>
moderationComment?: string
video?: VideoAbuse
comment?: VideoCommentAbuse
createdAt: Date
updatedAt: Date
// FIXME: deprecated in 2.3, remove this
startAt: null
endAt: null
count?: number
nth?: number
countReportsForReporter?: number
countReportsForReportee?: number
}

View File

@ -0,0 +1,6 @@
export * from './abuse-create.model'
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'

View File

@ -1,2 +1,3 @@
export * from './abuse'
export * from './account-block.model' export * from './account-block.model'
export * from './server-block.model' export * from './server-block.model'

View File

@ -64,9 +64,20 @@ export interface UserNotification {
video: VideoInfo video: VideoInfo
} }
videoAbuse?: { abuse?: {
id: number id: number
video: VideoInfo
video?: VideoInfo
comment?: {
threadId: number
video: {
uuid: string
}
}
account?: ActorInfo
} }
videoBlacklist?: { videoBlacklist?: {

View File

@ -11,7 +11,7 @@ export enum UserRight {
MANAGE_SERVER_REDUNDANCY, MANAGE_SERVER_REDUNDANCY,
MANAGE_VIDEO_ABUSES, MANAGE_ABUSES,
MANAGE_JOBS, MANAGE_JOBS,

View File

@ -20,7 +20,7 @@ const userRoleRights: { [ id in UserRole ]: UserRight[] } = {
[UserRole.MODERATOR]: [ [UserRole.MODERATOR]: [
UserRight.MANAGE_VIDEO_BLACKLIST, UserRight.MANAGE_VIDEO_BLACKLIST,
UserRight.MANAGE_VIDEO_ABUSES, UserRight.MANAGE_ABUSES,
UserRight.REMOVE_ANY_VIDEO, UserRight.REMOVE_ANY_VIDEO,
UserRight.REMOVE_ANY_VIDEO_CHANNEL, UserRight.REMOVE_ANY_VIDEO_CHANNEL,
UserRight.REMOVE_ANY_VIDEO_PLAYLIST, UserRight.REMOVE_ANY_VIDEO_PLAYLIST,

View File

@ -1,6 +0,0 @@
export * from './video-abuse-create.model'
export * from './video-abuse-reason.model'
export * from './video-abuse-state.model'
export * from './video-abuse-update.model'
export * from './video-abuse-video-is.type'
export * from './video-abuse.model'

View File

@ -1,8 +0,0 @@
import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
export interface VideoAbuseCreate {
reason: string
predefinedReasons?: VideoAbusePredefinedReasonsString[]
startAt?: number
endAt?: number
}

View File

@ -1,33 +0,0 @@
export enum VideoAbusePredefinedReasons {
VIOLENT_OR_REPULSIVE = 1,
HATEFUL_OR_ABUSIVE,
SPAM_OR_MISLEADING,
PRIVACY,
RIGHTS,
SERVER_RULES,
THUMBNAILS,
CAPTIONS
}
export type VideoAbusePredefinedReasonsString =
'violentOrRepulsive' |
'hatefulOrAbusive' |
'spamOrMisleading' |
'privacy' |
'rights' |
'serverRules' |
'thumbnails' |
'captions'
export const videoAbusePredefinedReasonsMap: {
[key in VideoAbusePredefinedReasonsString]: VideoAbusePredefinedReasons
} = {
violentOrRepulsive: VideoAbusePredefinedReasons.VIOLENT_OR_REPULSIVE,
hatefulOrAbusive: VideoAbusePredefinedReasons.HATEFUL_OR_ABUSIVE,
spamOrMisleading: VideoAbusePredefinedReasons.SPAM_OR_MISLEADING,
privacy: VideoAbusePredefinedReasons.PRIVACY,
rights: VideoAbusePredefinedReasons.RIGHTS,
serverRules: VideoAbusePredefinedReasons.SERVER_RULES,
thumbnails: VideoAbusePredefinedReasons.THUMBNAILS,
captions: VideoAbusePredefinedReasons.CAPTIONS
}

View File

@ -1,6 +0,0 @@
import { VideoAbuseState } from './video-abuse-state.model'
export interface VideoAbuseUpdate {
moderationComment?: string
state?: VideoAbuseState
}

Some files were not shown because too many files have changed in this diff Show More