diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2020-06-22 13:00:39 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-06-22 13:00:39 +0200 |
commit | 1ebddadd0704812a4600c39cabe2268321e88331 (patch) | |
tree | 1cc8560e5b63e9976aa5411ba800a62cfe7b8ea9 | |
parent | 07aea1a2642fc9868cb01e30c322514029d5b95a (diff) | |
download | PeerTube-1ebddadd0704812a4600c39cabe2268321e88331.tar.gz PeerTube-1ebddadd0704812a4600c39cabe2268321e88331.tar.zst PeerTube-1ebddadd0704812a4600c39cabe2268321e88331.zip |
predefined report reasons & improved reporter UI (#2842)
- added `startAt` and `endAt` optional timestamps to help pin down reported sections of a video
- added predefined report reasons
- added video player with report modal
35 files changed, 657 insertions, 95 deletions
diff --git a/client/src/app/+admin/moderation/moderation.component.scss b/client/src/app/+admin/moderation/moderation.component.scss index ba68cf6f6..0ec420af9 100644 --- a/client/src/app/+admin/moderation/moderation.component.scss +++ b/client/src/app/+admin/moderation/moderation.component.scss | |||
@@ -42,6 +42,20 @@ | |||
42 | } | 42 | } |
43 | } | 43 | } |
44 | 44 | ||
45 | p-calendar { | ||
46 | display: block; | ||
47 | |||
48 | ::ng-deep { | ||
49 | .ui-widget-content { | ||
50 | min-width: 400px; | ||
51 | } | ||
52 | |||
53 | input { | ||
54 | @include peertube-input-text(100%); | ||
55 | } | ||
56 | } | ||
57 | } | ||
58 | |||
45 | .screenratio { | 59 | .screenratio { |
46 | div { | 60 | div { |
47 | @include miniature-thumbnail; | 61 | @include miniature-thumbnail; |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html index 453a282d1..5512bb1de 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html | |||
@@ -57,6 +57,22 @@ | |||
57 | <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span> | 57 | <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span> |
58 | </div> | 58 | </div> |
59 | 59 | ||
60 | <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex"> | ||
61 | <span class="col-3"></span> | ||
62 | <span class="col-9"> | ||
63 | <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()"> | ||
64 | <div>{{ reason.label }}</div> | ||
65 | </a> | ||
66 | </span> | ||
67 | </div> | ||
68 | |||
69 | <div *ngIf="videoAbuse.startAt" class="mt-2 d-flex"> | ||
70 | <span class="col-3 moderation-expanded-label" i18n>Reported part</span> | ||
71 | <span class="col-9"> | ||
72 | {{ startAt }}<ng-container *ngIf="videoAbuse.endAt"> - {{ endAt }}</ng-container> | ||
73 | </span> | ||
74 | </div> | ||
75 | |||
60 | <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment"> | 76 | <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment"> |
61 | <span class="col-3 moderation-expanded-label" i18n>Note</span> | 77 | <span class="col-3 moderation-expanded-label" i18n>Note</span> |
62 | <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span> | 78 | <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span> |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts index d9cb19845..13485124f 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts | |||
@@ -1,7 +1,9 @@ | |||
1 | import { Component, Input } from '@angular/core' | 1 | import { Component, Input } from '@angular/core' |
2 | import { Account } from '@app/shared/account/account.model' | ||
3 | import { Actor } from '@app/shared/actor/actor.model' | 2 | import { Actor } from '@app/shared/actor/actor.model' |
3 | import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model' | ||
4 | import { ProcessedVideoAbuse } from './video-abuse-list.component' | 4 | import { ProcessedVideoAbuse } from './video-abuse-list.component' |
5 | import { I18n } from '@ngx-translate/i18n-polyfill' | ||
6 | import { durationToString } from '@app/shared/misc/utils' | ||
5 | 7 | ||
6 | @Component({ | 8 | @Component({ |
7 | selector: 'my-video-abuse-details', | 9 | selector: 'my-video-abuse-details', |
@@ -11,6 +13,39 @@ import { ProcessedVideoAbuse } from './video-abuse-list.component' | |||
11 | export class VideoAbuseDetailsComponent { | 13 | export class VideoAbuseDetailsComponent { |
12 | @Input() videoAbuse: ProcessedVideoAbuse | 14 | @Input() videoAbuse: ProcessedVideoAbuse |
13 | 15 | ||
16 | private predefinedReasonsTranslations: { [key in VideoAbusePredefinedReasonsString]: string } | ||
17 | |||
18 | constructor ( | ||
19 | private i18n: I18n | ||
20 | ) { | ||
21 | this.predefinedReasonsTranslations = { | ||
22 | violentOrRepulsive: this.i18n('Violent or Repulsive'), | ||
23 | hatefulOrAbusive: this.i18n('Hateful or Abusive'), | ||
24 | spamOrMisleading: this.i18n('Spam or Misleading'), | ||
25 | privacy: this.i18n('Privacy'), | ||
26 | rights: this.i18n('Rights'), | ||
27 | serverRules: this.i18n('Server rules'), | ||
28 | thumbnails: this.i18n('Thumbnails'), | ||
29 | captions: this.i18n('Captions') | ||
30 | } | ||
31 | } | ||
32 | |||
33 | get startAt () { | ||
34 | return durationToString(this.videoAbuse.startAt) | ||
35 | } | ||
36 | |||
37 | get endAt () { | ||
38 | return durationToString(this.videoAbuse.endAt) | ||
39 | } | ||
40 | |||
41 | getPredefinedReasons () { | ||
42 | if (!this.videoAbuse.predefinedReasons) return [] | ||
43 | return this.videoAbuse.predefinedReasons.map(r => ({ | ||
44 | id: r, | ||
45 | label: this.predefinedReasonsTranslations[r] | ||
46 | })) | ||
47 | } | ||
48 | |||
14 | switchToDefaultAvatar ($event: Event) { | 49 | switchToDefaultAvatar ($event: Event) { |
15 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() | 50 | ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() |
16 | } | 51 | } |
diff --git a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts index a36acc2ab..d7f5beef3 100644 --- a/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts +++ b/client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts | |||
@@ -11,13 +11,13 @@ import { ModerationCommentModalComponent } from './moderation-comment-modal.comp | |||
11 | import { Video } from '../../../shared/video/video.model' | 11 | import { Video } from '../../../shared/video/video.model' |
12 | import { MarkdownService } from '@app/shared/renderer' | 12 | import { MarkdownService } from '@app/shared/renderer' |
13 | import { Actor } from '@app/shared/actor/actor.model' | 13 | import { Actor } from '@app/shared/actor/actor.model' |
14 | import { buildVideoLink, buildVideoEmbed } from 'src/assets/player/utils' | 14 | import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' |
15 | import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' | ||
16 | import { DomSanitizer } from '@angular/platform-browser' | 15 | import { DomSanitizer } from '@angular/platform-browser' |
17 | import { BlocklistService } from '@app/shared/blocklist' | 16 | import { BlocklistService } from '@app/shared/blocklist' |
18 | import { VideoService } from '@app/shared/video/video.service' | 17 | import { VideoService } from '@app/shared/video/video.service' |
19 | import { ActivatedRoute, Params, Router } from '@angular/router' | 18 | import { ActivatedRoute, Params, Router } from '@angular/router' |
20 | import { filter } from 'rxjs/operators' | 19 | import { filter } from 'rxjs/operators' |
20 | import { environment } from 'src/environments/environment' | ||
21 | 21 | ||
22 | export type ProcessedVideoAbuse = VideoAbuse & { | 22 | export type ProcessedVideoAbuse = VideoAbuse & { |
23 | moderationCommentHtml?: string, | 23 | moderationCommentHtml?: string, |
@@ -259,12 +259,15 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV | |||
259 | } | 259 | } |
260 | 260 | ||
261 | getVideoEmbed (videoAbuse: VideoAbuse) { | 261 | getVideoEmbed (videoAbuse: VideoAbuse) { |
262 | const absoluteAPIUrl = getAbsoluteAPIUrl() | 262 | return buildVideoEmbed( |
263 | const embedUrl = buildVideoLink({ | 263 | buildVideoLink({ |
264 | baseUrl: absoluteAPIUrl + '/videos/embed/' + videoAbuse.video.uuid, | 264 | baseUrl: `${environment.embedUrl}/videos/embed/${videoAbuse.video.uuid}`, |
265 | warningTitle: false | 265 | title: false, |
266 | }) | 266 | warningTitle: false, |
267 | return buildVideoEmbed(embedUrl) | 267 | startTime: videoAbuse.startAt, |
268 | stopTime: videoAbuse.endAt | ||
269 | }) | ||
270 | ) | ||
268 | } | 271 | } |
269 | 272 | ||
270 | switchToDefaultAvatar ($event: Event) { | 273 | switchToDefaultAvatar ($event: Event) { |
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts index cd6db1f3c..78558851a 100644 --- a/client/src/app/shared/rest/rest.service.ts +++ b/client/src/app/shared/rest/rest.service.ts | |||
@@ -46,7 +46,7 @@ export class RestService { | |||
46 | addObjectParams (params: HttpParams, object: { [ name: string ]: any }) { | 46 | addObjectParams (params: HttpParams, object: { [ name: string ]: any }) { |
47 | for (const name of Object.keys(object)) { | 47 | for (const name of Object.keys(object)) { |
48 | const value = object[name] | 48 | const value = object[name] |
49 | if (!value) continue | 49 | if (value === undefined || value === null) continue |
50 | 50 | ||
51 | if (Array.isArray(value) && value.length !== 0) { | 51 | if (Array.isArray(value) && value.length !== 0) { |
52 | for (const v of value) params = params.append(name, v) | 52 | for (const v of value) params = params.append(name, v) |
@@ -93,7 +93,7 @@ export class RestService { | |||
93 | 93 | ||
94 | return t | 94 | return t |
95 | }) | 95 | }) |
96 | .filter(t => !!t) | 96 | .filter(t => !!t || t === 0) |
97 | 97 | ||
98 | if (matchedTokens.length === 0) continue | 98 | if (matchedTokens.length === 0) continue |
99 | 99 | ||
@@ -103,7 +103,7 @@ export class RestService { | |||
103 | } | 103 | } |
104 | 104 | ||
105 | return { | 105 | return { |
106 | search: searchTokens.join(' '), | 106 | search: searchTokens.join(' ') || undefined, |
107 | 107 | ||
108 | ...additionalFilters | 108 | ...additionalFilters |
109 | } | 109 | } |
diff --git a/client/src/app/shared/video-abuse/video-abuse.service.ts b/client/src/app/shared/video-abuse/video-abuse.service.ts index 700a30239..43f4674b1 100644 --- a/client/src/app/shared/video-abuse/video-abuse.service.ts +++ b/client/src/app/shared/video-abuse/video-abuse.service.ts | |||
@@ -3,9 +3,10 @@ import { HttpClient, HttpParams } from '@angular/common/http' | |||
3 | import { Injectable } from '@angular/core' | 3 | import { Injectable } from '@angular/core' |
4 | import { SortMeta } from 'primeng/api' | 4 | import { SortMeta } from 'primeng/api' |
5 | import { Observable } from 'rxjs' | 5 | import { Observable } from 'rxjs' |
6 | import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared' | 6 | import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared' |
7 | import { environment } from '../../../environments/environment' | 7 | import { environment } from '../../../environments/environment' |
8 | import { RestExtractor, RestPagination, RestService } from '../rest' | 8 | import { RestExtractor, RestPagination, RestService } from '../rest' |
9 | import { omit } from 'lodash-es' | ||
9 | 10 | ||
10 | @Injectable() | 11 | @Injectable() |
11 | export class VideoAbuseService { | 12 | export class VideoAbuseService { |
@@ -51,7 +52,8 @@ export class VideoAbuseService { | |||
51 | } | 52 | } |
52 | }, | 53 | }, |
53 | searchReporter: { prefix: 'reporter:' }, | 54 | searchReporter: { prefix: 'reporter:' }, |
54 | searchReportee: { prefix: 'reportee:' } | 55 | searchReportee: { prefix: 'reportee:' }, |
56 | predefinedReason: { prefix: 'tag:' } | ||
55 | }) | 57 | }) |
56 | 58 | ||
57 | params = this.restService.addObjectParams(params, filters) | 59 | params = this.restService.addObjectParams(params, filters) |
@@ -63,9 +65,10 @@ export class VideoAbuseService { | |||
63 | ) | 65 | ) |
64 | } | 66 | } |
65 | 67 | ||
66 | reportVideo (id: number, reason: string) { | 68 | reportVideo (parameters: { id: number } & VideoAbuseCreate) { |
67 | const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse' | 69 | const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse' |
68 | const body = { reason } | 70 | |
71 | const body = omit(parameters, [ 'id' ]) | ||
69 | 72 | ||
70 | return this.authHttp.post(url, body) | 73 | return this.authHttp.post(url, body) |
71 | .pipe( | 74 | .pipe( |
diff --git a/client/src/app/shared/video/modals/video-block.component.html b/client/src/app/shared/video/modals/video-block.component.html index a8dd30b5e..5e73d66c5 100644 --- a/client/src/app/shared/video/modals/video-block.component.html +++ b/client/src/app/shared/video/modals/video-block.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <ng-template #modal> | 1 | <ng-template #modal> |
2 | <div class="modal-header"> | 2 | <div class="modal-header"> |
3 | <h4 i18n class="modal-title">Blocklist video</h4> | 3 | <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4> |
4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | 4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> |
5 | </div> | 5 | </div> |
6 | 6 | ||
@@ -9,7 +9,7 @@ | |||
9 | <form novalidate [formGroup]="form" (ngSubmit)="block()"> | 9 | <form novalidate [formGroup]="form" (ngSubmit)="block()"> |
10 | <div class="form-group"> | 10 | <div class="form-group"> |
11 | <textarea | 11 | <textarea |
12 | i18n-placeholder placeholder="Reason..." formControlName="reason" | 12 | i18n-placeholder placeholder="Please describe the reason..." formControlName="reason" |
13 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | 13 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" |
14 | ></textarea> | 14 | ></textarea> |
15 | <div *ngIf="formErrors.reason" class="form-error"> | 15 | <div *ngIf="formErrors.reason" class="form-error"> |
diff --git a/client/src/app/shared/video/modals/video-report.component.html b/client/src/app/shared/video/modals/video-report.component.html index e336b6660..d6beb6d2a 100644 --- a/client/src/app/shared/video/modals/video-report.component.html +++ b/client/src/app/shared/video/modals/video-report.component.html | |||
@@ -1,38 +1,97 @@ | |||
1 | <ng-template #modal> | 1 | <ng-template #modal> |
2 | <div class="modal-header"> | 2 | <div class="modal-header"> |
3 | <h4 i18n class="modal-title">Report video</h4> | 3 | <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4> |
4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> | 4 | <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> |
5 | </div> | 5 | </div> |
6 | 6 | ||
7 | <div class="modal-body"> | 7 | <div class="modal-body"> |
8 | <form novalidate [formGroup]="form" (ngSubmit)="report()"> | ||
8 | 9 | ||
9 | <div i18n class="information"> | 10 | <div class="row"> |
10 | Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. | 11 | <div class="col-5 form-group"> |
11 | </div> | 12 | |
13 | <label i18n for="reportPredefinedReasons">What is the issue?</label> | ||
14 | |||
15 | <div class="ml-2 mt-2 d-flex flex-column"> | ||
16 | <ng-container formGroupName="predefinedReasons"> | ||
17 | <div class="form-group" *ngFor="let reason of predefinedReasons"> | ||
18 | <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}"> | ||
19 | <ng-template *ngIf="reason.help" ptTemplate="help"> | ||
20 | <div [innerHTML]="reason.help"></div> | ||
21 | </ng-template> | ||
22 | <ng-container *ngIf="reason.description" ngProjectAs="description"> | ||
23 | <div [innerHTML]="reason.description"></div> | ||
24 | </ng-container> | ||
25 | </my-peertube-checkbox> | ||
26 | </div> | ||
27 | </ng-container> | ||
28 | </div> | ||
12 | 29 | ||
13 | <form novalidate [formGroup]="form" (ngSubmit)="report()"> | ||
14 | <div class="form-group"> | ||
15 | <textarea | ||
16 | i18n-placeholder placeholder="Reason..." formControlName="reason" | ||
17 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | ||
18 | ></textarea> | ||
19 | <div *ngIf="formErrors.reason" class="form-error"> | ||
20 | {{ formErrors.reason }} | ||
21 | </div> | ||
22 | </div> | 30 | </div> |
23 | 31 | ||
24 | <div class="form-group inputs"> | 32 | <div class="col-7"> |
25 | <input | 33 | <div class="row justify-content-center"> |
26 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | 34 | <div class="col-12 col-lg-9 mb-2"> |
27 | (click)="hide()" (key.enter)="hide()" | 35 | <div class="screenratio"> |
28 | > | 36 | <div [innerHTML]="embedHtml"></div> |
37 | </div> | ||
38 | </div> | ||
39 | </div> | ||
40 | |||
41 | <div class="mb-1 start-at" formGroupName="timestamp"> | ||
42 | <my-peertube-checkbox | ||
43 | formControlName="hasStart" | ||
44 | i18n-labelText labelText="Start at" | ||
45 | ></my-peertube-checkbox> | ||
46 | |||
47 | <my-timestamp-input | ||
48 | [timestamp]="timestamp.startAt" | ||
49 | [maxTimestamp]="video.duration" | ||
50 | formControlName="startAt" | ||
51 | inputName="startAt" | ||
52 | > | ||
53 | </my-timestamp-input> | ||
54 | </div> | ||
55 | |||
56 | <div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart"> | ||
57 | <my-peertube-checkbox | ||
58 | formControlName="hasEnd" | ||
59 | i18n-labelText labelText="Stop at" | ||
60 | ></my-peertube-checkbox> | ||
29 | 61 | ||
30 | <input | 62 | <my-timestamp-input |
31 | type="submit" i18n-value value="Submit" class="action-button-submit" | 63 | [timestamp]="timestamp.endAt" |
32 | [disabled]="!form.valid" | 64 | [maxTimestamp]="video.duration" |
33 | > | 65 | formControlName="endAt" |
66 | inputName="endAt" | ||
67 | > | ||
68 | </my-timestamp-input> | ||
69 | </div> | ||
70 | |||
71 | <div i18n class="information"> | ||
72 | Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. | ||
73 | </div> | ||
74 | |||
75 | <div class="form-group"> | ||
76 | <textarea | ||
77 | i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus | ||
78 | [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" | ||
79 | ></textarea> | ||
80 | <div *ngIf="formErrors.reason" class="form-error"> | ||
81 | {{ formErrors.reason }} | ||
82 | </div> | ||
83 | </div> | ||
34 | </div> | 84 | </div> |
35 | </form> | 85 | </div> |
36 | 86 | ||
87 | <div class="form-group inputs"> | ||
88 | <input | ||
89 | type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" | ||
90 | (click)="hide()" (key.enter)="hide()" | ||
91 | > | ||
92 | <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid"> | ||
93 | </div> | ||
94 | |||
95 | </form> | ||
37 | </div> | 96 | </div> |
38 | </ng-template> | 97 | </ng-template> |
diff --git a/client/src/app/shared/video/modals/video-report.component.scss b/client/src/app/shared/video/modals/video-report.component.scss index 4713660a2..b2606cbd8 100644 --- a/client/src/app/shared/video/modals/video-report.component.scss +++ b/client/src/app/shared/video/modals/video-report.component.scss | |||
@@ -8,3 +8,20 @@ | |||
8 | textarea { | 8 | textarea { |
9 | @include peertube-textarea(100%, 100px); | 9 | @include peertube-textarea(100%, 100px); |
10 | } | 10 | } |
11 | |||
12 | .start-at, | ||
13 | .stop-at { | ||
14 | width: 300px; | ||
15 | display: flex; | ||
16 | align-items: center; | ||
17 | |||
18 | my-timestamp-input { | ||
19 | margin-left: 10px; | ||
20 | } | ||
21 | } | ||
22 | |||
23 | .screenratio { | ||
24 | @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { | ||
25 | left: 0; | ||
26 | }; | ||
27 | } | ||
diff --git a/client/src/app/shared/video/modals/video-report.component.ts b/client/src/app/shared/video/modals/video-report.component.ts index 988fa03d4..c2d441bba 100644 --- a/client/src/app/shared/video/modals/video-report.component.ts +++ b/client/src/app/shared/video/modals/video-report.component.ts | |||
@@ -8,6 +8,10 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | |||
8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' | 8 | import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' |
9 | import { VideoAbuseService } from '@app/shared/video-abuse' | 9 | import { VideoAbuseService } from '@app/shared/video-abuse' |
10 | import { Video } from '@app/shared/video/video.model' | 10 | import { Video } from '@app/shared/video/video.model' |
11 | import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' | ||
12 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser' | ||
13 | import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model' | ||
14 | import { mapValues, pickBy } from 'lodash-es' | ||
11 | 15 | ||
12 | @Component({ | 16 | @Component({ |
13 | selector: 'my-video-report', | 17 | selector: 'my-video-report', |
@@ -20,6 +24,8 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
20 | @ViewChild('modal', { static: true }) modal: NgbModal | 24 | @ViewChild('modal', { static: true }) modal: NgbModal |
21 | 25 | ||
22 | error: string = null | 26 | error: string = null |
27 | predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] | ||
28 | embedHtml: SafeHtml | ||
23 | 29 | ||
24 | private openedModal: NgbModalRef | 30 | private openedModal: NgbModalRef |
25 | 31 | ||
@@ -29,6 +35,7 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
29 | private videoAbuseValidatorsService: VideoAbuseValidatorsService, | 35 | private videoAbuseValidatorsService: VideoAbuseValidatorsService, |
30 | private videoAbuseService: VideoAbuseService, | 36 | private videoAbuseService: VideoAbuseService, |
31 | private notifier: Notifier, | 37 | private notifier: Notifier, |
38 | private sanitizer: DomSanitizer, | ||
32 | private i18n: I18n | 39 | private i18n: I18n |
33 | ) { | 40 | ) { |
34 | super() | 41 | super() |
@@ -46,14 +53,82 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
46 | return '' | 53 | return '' |
47 | } | 54 | } |
48 | 55 | ||
56 | get timestamp () { | ||
57 | return this.form.get('timestamp').value | ||
58 | } | ||
59 | |||
60 | getVideoEmbed () { | ||
61 | return this.sanitizer.bypassSecurityTrustHtml( | ||
62 | buildVideoEmbed( | ||
63 | buildVideoLink({ | ||
64 | baseUrl: this.video.embedUrl, | ||
65 | title: false, | ||
66 | warningTitle: false | ||
67 | }) | ||
68 | ) | ||
69 | ) | ||
70 | } | ||
71 | |||
49 | ngOnInit () { | 72 | ngOnInit () { |
50 | this.buildForm({ | 73 | this.buildForm({ |
51 | reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON | 74 | reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON, |
75 | predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null), | ||
76 | timestamp: { | ||
77 | hasStart: null, | ||
78 | startAt: null, | ||
79 | hasEnd: null, | ||
80 | endAt: null | ||
81 | } | ||
52 | }) | 82 | }) |
83 | |||
84 | this.predefinedReasons = [ | ||
85 | { | ||
86 | id: 'violentOrRepulsive', | ||
87 | label: this.i18n('Violent or repulsive'), | ||
88 | help: this.i18n('Contains offensive, violent, or coarse language or iconography.') | ||
89 | }, | ||
90 | { | ||
91 | id: 'hatefulOrAbusive', | ||
92 | label: this.i18n('Hateful or abusive'), | ||
93 | help: this.i18n('Contains abusive, racist or sexist language or iconography.') | ||
94 | }, | ||
95 | { | ||
96 | id: 'spamOrMisleading', | ||
97 | label: this.i18n('Spam, ad or false news'), | ||
98 | help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.') | ||
99 | }, | ||
100 | { | ||
101 | id: 'privacy', | ||
102 | label: this.i18n('Privacy breach or doxxing'), | ||
103 | help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).') | ||
104 | }, | ||
105 | { | ||
106 | id: 'rights', | ||
107 | label: this.i18n('Intellectual property violation'), | ||
108 | help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.') | ||
109 | }, | ||
110 | { | ||
111 | id: 'serverRules', | ||
112 | label: this.i18n('Breaks server rules'), | ||
113 | description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.') | ||
114 | }, | ||
115 | { | ||
116 | id: 'thumbnails', | ||
117 | label: this.i18n('Thumbnails'), | ||
118 | help: this.i18n('The above can only be seen in thumbnails.') | ||
119 | }, | ||
120 | { | ||
121 | id: 'captions', | ||
122 | label: this.i18n('Captions'), | ||
123 | help: this.i18n('The above can only be seen in captions (please describe which).') | ||
124 | } | ||
125 | ] | ||
126 | |||
127 | this.embedHtml = this.getVideoEmbed() | ||
53 | } | 128 | } |
54 | 129 | ||
55 | show () { | 130 | show () { |
56 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) | 131 | this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) |
57 | } | 132 | } |
58 | 133 | ||
59 | hide () { | 134 | hide () { |
@@ -62,17 +137,24 @@ export class VideoReportComponent extends FormReactive implements OnInit { | |||
62 | } | 137 | } |
63 | 138 | ||
64 | report () { | 139 | report () { |
65 | const reason = this.form.value['reason'] | 140 | const reason = this.form.get('reason').value |
141 | const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[] | ||
142 | const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value | ||
66 | 143 | ||
67 | this.videoAbuseService.reportVideo(this.video.id, reason) | 144 | this.videoAbuseService.reportVideo({ |
68 | .subscribe( | 145 | id: this.video.id, |
69 | () => { | 146 | reason, |
70 | this.notifier.success(this.i18n('Video reported.')) | 147 | predefinedReasons, |
71 | this.hide() | 148 | startAt: hasStart && startAt ? startAt : undefined, |
72 | }, | 149 | endAt: hasEnd && endAt ? endAt : undefined |
150 | }).subscribe( | ||
151 | () => { | ||
152 | this.notifier.success(this.i18n('Video reported.')) | ||
153 | this.hide() | ||
154 | }, | ||
73 | 155 | ||
74 | err => this.notifier.error(err.message) | 156 | err => this.notifier.error(err.message) |
75 | ) | 157 | ) |
76 | } | 158 | } |
77 | 159 | ||
78 | isRemoteVideo () { | 160 | isRemoteVideo () { |
diff --git a/client/src/app/shared/video/video.model.ts b/client/src/app/shared/video/video.model.ts index 16e43cbd8..dc5f45626 100644 --- a/client/src/app/shared/video/video.model.ts +++ b/client/src/app/shared/video/video.model.ts | |||
@@ -7,6 +7,7 @@ import { peertubeTranslate, ServerConfig } from '../../../../../shared/models' | |||
7 | import { Actor } from '@app/shared/actor/actor.model' | 7 | import { Actor } from '@app/shared/actor/actor.model' |
8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' | 8 | import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' |
9 | import { AuthUser } from '@app/core' | 9 | import { AuthUser } from '@app/core' |
10 | import { environment } from '../../../environments/environment' | ||
10 | 11 | ||
11 | export class Video implements VideoServerModel { | 12 | export class Video implements VideoServerModel { |
12 | byVideoChannel: string | 13 | byVideoChannel: string |
@@ -111,7 +112,7 @@ export class Video implements VideoServerModel { | |||
111 | this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) | 112 | this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) |
112 | 113 | ||
113 | this.embedPath = hash.embedPath | 114 | this.embedPath = hash.embedPath |
114 | this.embedUrl = hash.embedUrl || (absoluteAPIUrl + hash.embedPath) | 115 | this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath) |
115 | 116 | ||
116 | this.url = hash.url | 117 | this.url = hash.url |
117 | 118 | ||
diff --git a/client/src/environments/environment.e2e.ts b/client/src/environments/environment.e2e.ts index 7c00e8d4f..7724d27c9 100644 --- a/client/src/environments/environment.e2e.ts +++ b/client/src/environments/environment.e2e.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | export const environment = { | 1 | export const environment = { |
2 | production: false, | 2 | production: false, |
3 | hmr: false, | 3 | hmr: false, |
4 | apiUrl: 'http://localhost:9001' | 4 | apiUrl: 'http://localhost:9001', |
5 | embedUrl: 'http://localhost:9001/videos/embed' | ||
5 | } | 6 | } |
diff --git a/client/src/environments/environment.hmr.ts b/client/src/environments/environment.hmr.ts index 853e20803..72eed45e5 100644 --- a/client/src/environments/environment.hmr.ts +++ b/client/src/environments/environment.hmr.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | export const environment = { | 1 | export const environment = { |
2 | production: false, | 2 | production: false, |
3 | hmr: true, | 3 | hmr: true, |
4 | apiUrl: '' | 4 | apiUrl: '', |
5 | embedUrl: 'http://localhost:9000/videos/embed' | ||
5 | } | 6 | } |
diff --git a/client/src/environments/environment.prod.ts b/client/src/environments/environment.prod.ts index d5dfe5573..368aa1389 100644 --- a/client/src/environments/environment.prod.ts +++ b/client/src/environments/environment.prod.ts | |||
@@ -1,5 +1,6 @@ | |||
1 | export const environment = { | 1 | export const environment = { |
2 | production: true, | 2 | production: true, |
3 | hmr: false, | 3 | hmr: false, |
4 | apiUrl: '' | 4 | apiUrl: '', |
5 | embedUrl: '/videos/embed' | ||
5 | } | 6 | } |
diff --git a/client/src/environments/environment.ts b/client/src/environments/environment.ts index b6bc784b5..60f5d9450 100644 --- a/client/src/environments/environment.ts +++ b/client/src/environments/environment.ts | |||
@@ -11,5 +11,6 @@ import 'core-js/features/reflect' | |||
11 | export const environment = { | 11 | export const environment = { |
12 | production: false, | 12 | production: false, |
13 | hmr: false, | 13 | hmr: false, |
14 | apiUrl: 'http://localhost:9000' | 14 | apiUrl: 'http://localhost:9000', |
15 | embedUrl: 'http://localhost:9000/videos/embed' | ||
15 | } | 16 | } |
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index eb80ea0e3..6a1deac76 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss | |||
@@ -804,10 +804,12 @@ | |||
804 | } | 804 | } |
805 | 805 | ||
806 | @mixin chip { | 806 | @mixin chip { |
807 | --chip-radius: 5rem; | ||
808 | --chip-padding: .2rem .4rem; | ||
807 | $avatar-height: 1.2rem; | 809 | $avatar-height: 1.2rem; |
808 | 810 | ||
809 | align-items: center; | 811 | align-items: center; |
810 | border-radius: 5rem; | 812 | border-radius: var(--chip-radius); |
811 | display: inline-flex; | 813 | display: inline-flex; |
812 | font-size: 90%; | 814 | font-size: 90%; |
813 | color: pvar(--mainForegroundColor); | 815 | color: pvar(--mainForegroundColor); |
@@ -816,12 +818,17 @@ | |||
816 | margin: .1rem; | 818 | margin: .1rem; |
817 | max-width: 320px; | 819 | max-width: 320px; |
818 | overflow: hidden; | 820 | overflow: hidden; |
819 | padding: .2rem .4rem; | 821 | padding: var(--chip-padding); |
820 | text-decoration: none; | 822 | text-decoration: none; |
821 | text-overflow: ellipsis; | 823 | text-overflow: ellipsis; |
822 | vertical-align: middle; | 824 | vertical-align: middle; |
823 | white-space: nowrap; | 825 | white-space: nowrap; |
824 | 826 | ||
827 | &.rectangular { | ||
828 | --chip-radius: .2rem; | ||
829 | --chip-padding: .2rem .3rem; | ||
830 | } | ||
831 | |||
825 | .avatar { | 832 | .avatar { |
826 | margin-left: -.4rem; | 833 | margin-left: -.4rem; |
827 | margin-right: .2rem; | 834 | margin-right: .2rem; |
diff --git a/client/src/sass/player/peertube-skin.scss b/client/src/sass/player/peertube-skin.scss index 1fc744e67..bdeff8f9a 100644 --- a/client/src/sass/player/peertube-skin.scss +++ b/client/src/sass/player/peertube-skin.scss | |||
@@ -86,7 +86,7 @@ body { | |||
86 | } | 86 | } |
87 | 87 | ||
88 | &.focus-visible, &:hover { | 88 | &.focus-visible, &:hover { |
89 | background-color: var(--mainColor); | 89 | background-color: var(--mainColor, dimgray); |
90 | } | 90 | } |
91 | 91 | ||
92 | } | 92 | } |
diff --git a/server/controllers/api/videos/abuse.ts b/server/controllers/api/videos/abuse.ts index 77843f149..ab2074459 100644 --- a/server/controllers/api/videos/abuse.ts +++ b/server/controllers/api/videos/abuse.ts | |||
@@ -1,5 +1,5 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse } from '../../../../shared' | 2 | import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse, videoAbusePredefinedReasonsMap } from '../../../../shared' |
3 | import { logger } from '../../../helpers/logger' | 3 | import { logger } from '../../../helpers/logger' |
4 | import { getFormattedObjects } from '../../../helpers/utils' | 4 | import { getFormattedObjects } from '../../../helpers/utils' |
5 | import { sequelizeTypescript } from '../../../initializers/database' | 5 | import { sequelizeTypescript } from '../../../initializers/database' |
@@ -74,6 +74,7 @@ async function listVideoAbuses (req: express.Request, res: express.Response) { | |||
74 | count: req.query.count, | 74 | count: req.query.count, |
75 | sort: req.query.sort, | 75 | sort: req.query.sort, |
76 | id: req.query.id, | 76 | id: req.query.id, |
77 | predefinedReason: req.query.predefinedReason, | ||
77 | search: req.query.search, | 78 | search: req.query.search, |
78 | state: req.query.state, | 79 | state: req.query.state, |
79 | videoIs: req.query.videoIs, | 80 | videoIs: req.query.videoIs, |
@@ -123,12 +124,16 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) { | |||
123 | 124 | ||
124 | const videoAbuseInstance = await sequelizeTypescript.transaction(async t => { | 125 | const videoAbuseInstance = await sequelizeTypescript.transaction(async t => { |
125 | reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) | 126 | reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) |
127 | const predefinedReasons = body.predefinedReasons?.map(r => videoAbusePredefinedReasonsMap[r]) | ||
126 | 128 | ||
127 | const abuseToCreate = { | 129 | const abuseToCreate = { |
128 | reporterAccountId: reporterAccount.id, | 130 | reporterAccountId: reporterAccount.id, |
129 | reason: body.reason, | 131 | reason: body.reason, |
130 | videoId: videoInstance.id, | 132 | videoId: videoInstance.id, |
131 | state: VideoAbuseState.PENDING | 133 | state: VideoAbuseState.PENDING, |
134 | predefinedReasons, | ||
135 | startAt: body.startAt, | ||
136 | endAt: body.endAt | ||
132 | } | 137 | } |
133 | 138 | ||
134 | const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(abuseToCreate, { transaction: t }) | 139 | const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(abuseToCreate, { transaction: t }) |
@@ -152,7 +157,7 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) { | |||
152 | reporter: reporterAccount.Actor.getIdentifier() | 157 | reporter: reporterAccount.Actor.getIdentifier() |
153 | }) | 158 | }) |
154 | 159 | ||
155 | logger.info('Abuse report for video %s created.', videoInstance.name) | 160 | logger.info('Abuse report for video "%s" created.', videoInstance.name) |
156 | 161 | ||
157 | return res.json({ videoAbuse: videoAbuseJSON }).end() | 162 | return res.json({ videoAbuse: videoAbuseJSON }).end() |
158 | } | 163 | } |
diff --git a/server/helpers/custom-validators/video-abuses.ts b/server/helpers/custom-validators/video-abuses.ts index 05e11b1c6..0c2c34268 100644 --- a/server/helpers/custom-validators/video-abuses.ts +++ b/server/helpers/custom-validators/video-abuses.ts | |||
@@ -1,8 +1,9 @@ | |||
1 | import validator from 'validator' | 1 | import validator from 'validator' |
2 | 2 | ||
3 | import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' | 3 | import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' |
4 | import { exists } from './misc' | 4 | import { exists, isArray } from './misc' |
5 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' | 5 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' |
6 | import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model' | ||
6 | 7 | ||
7 | const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES | 8 | const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES |
8 | 9 | ||
@@ -10,6 +11,22 @@ function isVideoAbuseReasonValid (value: string) { | |||
10 | return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON) | 11 | return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON) |
11 | } | 12 | } |
12 | 13 | ||
14 | function isVideoAbusePredefinedReasonValid (value: VideoAbusePredefinedReasonsString) { | ||
15 | return exists(value) && value in videoAbusePredefinedReasonsMap | ||
16 | } | ||
17 | |||
18 | function isVideoAbusePredefinedReasonsValid (value: VideoAbusePredefinedReasonsString[]) { | ||
19 | return exists(value) && isArray(value) && value.every(v => v in videoAbusePredefinedReasonsMap) | ||
20 | } | ||
21 | |||
22 | function isVideoAbuseTimestampValid (value: number) { | ||
23 | return value === null || (exists(value) && validator.isInt('' + value, { min: 0 })) | ||
24 | } | ||
25 | |||
26 | function isVideoAbuseTimestampCoherent (endAt: number, { req }) { | ||
27 | return exists(req.body.startAt) && endAt > req.body.startAt | ||
28 | } | ||
29 | |||
13 | function isVideoAbuseModerationCommentValid (value: string) { | 30 | function isVideoAbuseModerationCommentValid (value: string) { |
14 | return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT) | 31 | return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT) |
15 | } | 32 | } |
@@ -28,8 +45,12 @@ function isAbuseVideoIsValid (value: VideoAbuseVideoIs) { | |||
28 | // --------------------------------------------------------------------------- | 45 | // --------------------------------------------------------------------------- |
29 | 46 | ||
30 | export { | 47 | export { |
31 | isVideoAbuseStateValid, | ||
32 | isVideoAbuseReasonValid, | 48 | isVideoAbuseReasonValid, |
33 | isAbuseVideoIsValid, | 49 | isVideoAbusePredefinedReasonValid, |
34 | isVideoAbuseModerationCommentValid | 50 | isVideoAbusePredefinedReasonsValid, |
51 | isVideoAbuseTimestampValid, | ||
52 | isVideoAbuseTimestampCoherent, | ||
53 | isVideoAbuseModerationCommentValid, | ||
54 | isVideoAbuseStateValid, | ||
55 | isAbuseVideoIsValid | ||
35 | } | 56 | } |
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index 314f094b3..dd79c0e16 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts | |||
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config' | |||
14 | 14 | ||
15 | // --------------------------------------------------------------------------- | 15 | // --------------------------------------------------------------------------- |
16 | 16 | ||
17 | const LAST_MIGRATION_VERSION = 510 | 17 | const LAST_MIGRATION_VERSION = 515 |
18 | 18 | ||
19 | // --------------------------------------------------------------------------- | 19 | // --------------------------------------------------------------------------- |
20 | 20 | ||
diff --git a/server/initializers/migrations/0515-video-abuse-reason-timestamps.ts b/server/initializers/migrations/0515-video-abuse-reason-timestamps.ts new file mode 100644 index 000000000..c58335617 --- /dev/null +++ b/server/initializers/migrations/0515-video-abuse-reason-timestamps.ts | |||
@@ -0,0 +1,31 @@ | |||
1 | import * as Sequelize from 'sequelize' | ||
2 | |||
3 | async function up (utils: { | ||
4 | transaction: Sequelize.Transaction | ||
5 | queryInterface: Sequelize.QueryInterface | ||
6 | sequelize: Sequelize.Sequelize | ||
7 | }): Promise<void> { | ||
8 | await utils.queryInterface.addColumn('videoAbuse', 'predefinedReasons', { | ||
9 | type: Sequelize.ARRAY(Sequelize.INTEGER), | ||
10 | allowNull: true | ||
11 | }) | ||
12 | |||
13 | await utils.queryInterface.addColumn('videoAbuse', 'startAt', { | ||
14 | type: Sequelize.INTEGER, | ||
15 | allowNull: true | ||
16 | }) | ||
17 | |||
18 | await utils.queryInterface.addColumn('videoAbuse', 'endAt', { | ||
19 | type: Sequelize.INTEGER, | ||
20 | allowNull: true | ||
21 | }) | ||
22 | } | ||
23 | |||
24 | function down (options) { | ||
25 | throw new Error('Not implemented.') | ||
26 | } | ||
27 | |||
28 | export { | ||
29 | up, | ||
30 | down | ||
31 | } | ||
diff --git a/server/lib/activitypub/process/process-flag.ts b/server/lib/activitypub/process/process-flag.ts index 8d1c9c869..1d7132a3a 100644 --- a/server/lib/activitypub/process/process-flag.ts +++ b/server/lib/activitypub/process/process-flag.ts | |||
@@ -1,4 +1,9 @@ | |||
1 | import { ActivityCreate, ActivityFlag, VideoAbuseState } from '../../../../shared' | 1 | import { |
2 | ActivityCreate, | ||
3 | ActivityFlag, | ||
4 | VideoAbuseState, | ||
5 | videoAbusePredefinedReasonsMap | ||
6 | } from '../../../../shared' | ||
2 | import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects' | 7 | import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects' |
3 | import { retryTransactionWrapper } from '../../../helpers/database-utils' | 8 | import { retryTransactionWrapper } from '../../../helpers/database-utils' |
4 | import { logger } from '../../../helpers/logger' | 9 | import { logger } from '../../../helpers/logger' |
@@ -38,13 +43,21 @@ async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag, | |||
38 | 43 | ||
39 | const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object }) | 44 | const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object }) |
40 | const reporterAccount = await sequelizeTypescript.transaction(async t => AccountModel.load(account.id, t)) | 45 | const reporterAccount = await sequelizeTypescript.transaction(async t => AccountModel.load(account.id, t)) |
46 | const tags = Array.isArray(flag.tag) ? flag.tag : [] | ||
47 | const predefinedReasons = tags.map(tag => videoAbusePredefinedReasonsMap[tag.name]) | ||
48 | .filter(v => !isNaN(v)) | ||
49 | const startAt = flag.startAt | ||
50 | const endAt = flag.endAt | ||
41 | 51 | ||
42 | const videoAbuseInstance = await sequelizeTypescript.transaction(async t => { | 52 | const videoAbuseInstance = await sequelizeTypescript.transaction(async t => { |
43 | const videoAbuseData = { | 53 | const videoAbuseData = { |
44 | reporterAccountId: account.id, | 54 | reporterAccountId: account.id, |
45 | reason: flag.content, | 55 | reason: flag.content, |
46 | videoId: video.id, | 56 | videoId: video.id, |
47 | state: VideoAbuseState.PENDING | 57 | state: VideoAbuseState.PENDING, |
58 | predefinedReasons, | ||
59 | startAt, | ||
60 | endAt | ||
48 | } | 61 | } |
49 | 62 | ||
50 | const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(videoAbuseData, { transaction: t }) | 63 | const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(videoAbuseData, { transaction: t }) |
diff --git a/server/middlewares/validators/videos/video-abuses.ts b/server/middlewares/validators/videos/video-abuses.ts index 901997bcb..5bbd1e3c6 100644 --- a/server/middlewares/validators/videos/video-abuses.ts +++ b/server/middlewares/validators/videos/video-abuses.ts | |||
@@ -1,19 +1,46 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body, param, query } from 'express-validator' | 2 | import { body, param, query } from 'express-validator' |
3 | import { exists, isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' | 3 | import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' |
4 | import { | 4 | import { |
5 | isAbuseVideoIsValid, | 5 | isAbuseVideoIsValid, |
6 | isVideoAbuseModerationCommentValid, | 6 | isVideoAbuseModerationCommentValid, |
7 | isVideoAbuseReasonValid, | 7 | isVideoAbuseReasonValid, |
8 | isVideoAbuseStateValid | 8 | isVideoAbuseStateValid, |
9 | isVideoAbusePredefinedReasonsValid, | ||
10 | isVideoAbusePredefinedReasonValid, | ||
11 | isVideoAbuseTimestampValid, | ||
12 | isVideoAbuseTimestampCoherent | ||
9 | } from '../../../helpers/custom-validators/video-abuses' | 13 | } from '../../../helpers/custom-validators/video-abuses' |
10 | import { logger } from '../../../helpers/logger' | 14 | import { logger } from '../../../helpers/logger' |
11 | import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares' | 15 | import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares' |
12 | import { areValidationErrors } from '../utils' | 16 | import { areValidationErrors } from '../utils' |
13 | 17 | ||
14 | const videoAbuseReportValidator = [ | 18 | const videoAbuseReportValidator = [ |
15 | param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), | 19 | param('videoId') |
16 | body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'), | 20 | .custom(isIdOrUUIDValid) |
21 | .not() | ||
22 | .isEmpty() | ||
23 | .withMessage('Should have a valid videoId'), | ||
24 | body('reason') | ||
25 | .custom(isVideoAbuseReasonValid) | ||
26 | .withMessage('Should have a valid reason'), | ||
27 | body('predefinedReasons') | ||
28 | .optional() | ||
29 | .custom(isVideoAbusePredefinedReasonsValid) | ||
30 | .withMessage('Should have a valid list of predefined reasons'), | ||
31 | body('startAt') | ||
32 | .optional() | ||
33 | .customSanitizer(toIntOrNull) | ||
34 | .custom(isVideoAbuseTimestampValid) | ||
35 | .withMessage('Should have valid starting time value'), | ||
36 | body('endAt') | ||
37 | .optional() | ||
38 | .customSanitizer(toIntOrNull) | ||
39 | .custom(isVideoAbuseTimestampValid) | ||
40 | .withMessage('Should have valid ending time value') | ||
41 | .bail() | ||
42 | .custom(isVideoAbuseTimestampCoherent) | ||
43 | .withMessage('Should have a startAt timestamp beginning before endAt'), | ||
17 | 44 | ||
18 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | 45 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { |
19 | logger.debug('Checking videoAbuseReport parameters', { parameters: req.body }) | 46 | logger.debug('Checking videoAbuseReport parameters', { parameters: req.body }) |
@@ -63,6 +90,10 @@ const videoAbuseListValidator = [ | |||
63 | query('id') | 90 | query('id') |
64 | .optional() | 91 | .optional() |
65 | .custom(isIdValid).withMessage('Should have a valid id'), | 92 | .custom(isIdValid).withMessage('Should have a valid id'), |
93 | query('predefinedReason') | ||
94 | .optional() | ||
95 | .custom(isVideoAbusePredefinedReasonValid) | ||
96 | .withMessage('Should have a valid predefinedReason'), | ||
66 | query('search') | 97 | query('search') |
67 | .optional() | 98 | .optional() |
68 | .custom(exists).withMessage('Should have a valid search'), | 99 | .custom(exists).withMessage('Should have a valid search'), |
diff --git a/server/models/video/video-abuse.ts b/server/models/video/video-abuse.ts index b2f111337..1319332f0 100644 --- a/server/models/video/video-abuse.ts +++ b/server/models/video/video-abuse.ts | |||
@@ -15,7 +15,13 @@ import { | |||
15 | UpdatedAt | 15 | UpdatedAt |
16 | } from 'sequelize-typescript' | 16 | } from 'sequelize-typescript' |
17 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' | 17 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' |
18 | import { VideoAbuseState, VideoDetails } from '../../../shared' | 18 | import { |
19 | VideoAbuseState, | ||
20 | VideoDetails, | ||
21 | VideoAbusePredefinedReasons, | ||
22 | VideoAbusePredefinedReasonsString, | ||
23 | videoAbusePredefinedReasonsMap | ||
24 | } from '../../../shared' | ||
19 | import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' | 25 | import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' |
20 | import { VideoAbuse } from '../../../shared/models/videos' | 26 | import { VideoAbuse } from '../../../shared/models/videos' |
21 | import { | 27 | import { |
@@ -31,6 +37,7 @@ import { ThumbnailModel } from './thumbnail' | |||
31 | import { VideoModel } from './video' | 37 | import { VideoModel } from './video' |
32 | import { VideoBlacklistModel } from './video-blacklist' | 38 | import { VideoBlacklistModel } from './video-blacklist' |
33 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' | 39 | import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' |
40 | import { invert } from 'lodash' | ||
34 | 41 | ||
35 | export enum ScopeNames { | 42 | export enum ScopeNames { |
36 | FOR_API = 'FOR_API' | 43 | FOR_API = 'FOR_API' |
@@ -47,6 +54,7 @@ export enum ScopeNames { | |||
47 | 54 | ||
48 | // filters | 55 | // filters |
49 | id?: number | 56 | id?: number |
57 | predefinedReasonId?: number | ||
50 | 58 | ||
51 | state?: VideoAbuseState | 59 | state?: VideoAbuseState |
52 | videoIs?: VideoAbuseVideoIs | 60 | videoIs?: VideoAbuseVideoIs |
@@ -104,6 +112,14 @@ export enum ScopeNames { | |||
104 | }) | 112 | }) |
105 | } | 113 | } |
106 | 114 | ||
115 | if (options.predefinedReasonId) { | ||
116 | Object.assign(where, { | ||
117 | predefinedReasons: { | ||
118 | [Op.contains]: [ options.predefinedReasonId ] | ||
119 | } | ||
120 | }) | ||
121 | } | ||
122 | |||
107 | const onlyBlacklisted = options.videoIs === 'blacklisted' | 123 | const onlyBlacklisted = options.videoIs === 'blacklisted' |
108 | 124 | ||
109 | return { | 125 | return { |
@@ -258,6 +274,21 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
258 | @Column(DataType.JSONB) | 274 | @Column(DataType.JSONB) |
259 | deletedVideo: VideoDetails | 275 | deletedVideo: VideoDetails |
260 | 276 | ||
277 | @AllowNull(true) | ||
278 | @Default(null) | ||
279 | @Column(DataType.ARRAY(DataType.INTEGER)) | ||
280 | predefinedReasons: VideoAbusePredefinedReasons[] | ||
281 | |||
282 | @AllowNull(true) | ||
283 | @Default(null) | ||
284 | @Column | ||
285 | startAt: number | ||
286 | |||
287 | @AllowNull(true) | ||
288 | @Default(null) | ||
289 | @Column | ||
290 | endAt: number | ||
291 | |||
261 | @CreatedAt | 292 | @CreatedAt |
262 | createdAt: Date | 293 | createdAt: Date |
263 | 294 | ||
@@ -311,6 +342,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
311 | user?: MUserAccountId | 342 | user?: MUserAccountId |
312 | 343 | ||
313 | id?: number | 344 | id?: number |
345 | predefinedReason?: VideoAbusePredefinedReasonsString | ||
314 | state?: VideoAbuseState | 346 | state?: VideoAbuseState |
315 | videoIs?: VideoAbuseVideoIs | 347 | videoIs?: VideoAbuseVideoIs |
316 | 348 | ||
@@ -329,6 +361,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
329 | serverAccountId, | 361 | serverAccountId, |
330 | state, | 362 | state, |
331 | videoIs, | 363 | videoIs, |
364 | predefinedReason, | ||
332 | searchReportee, | 365 | searchReportee, |
333 | searchVideo, | 366 | searchVideo, |
334 | searchVideoChannel, | 367 | searchVideoChannel, |
@@ -337,6 +370,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
337 | } = parameters | 370 | } = parameters |
338 | 371 | ||
339 | const userAccountId = user ? user.Account.id : undefined | 372 | const userAccountId = user ? user.Account.id : undefined |
373 | const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined | ||
340 | 374 | ||
341 | const query = { | 375 | const query = { |
342 | offset: start, | 376 | offset: start, |
@@ -348,6 +382,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
348 | 382 | ||
349 | const filters = { | 383 | const filters = { |
350 | id, | 384 | id, |
385 | predefinedReasonId, | ||
351 | search, | 386 | search, |
352 | state, | 387 | state, |
353 | videoIs, | 388 | videoIs, |
@@ -360,7 +395,9 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
360 | } | 395 | } |
361 | 396 | ||
362 | return VideoAbuseModel | 397 | return VideoAbuseModel |
363 | .scope({ method: [ ScopeNames.FOR_API, filters ] }) | 398 | .scope([ |
399 | { method: [ ScopeNames.FOR_API, filters ] } | ||
400 | ]) | ||
364 | .findAndCountAll(query) | 401 | .findAndCountAll(query) |
365 | .then(({ rows, count }) => { | 402 | .then(({ rows, count }) => { |
366 | return { total: count, data: rows } | 403 | return { total: count, data: rows } |
@@ -368,6 +405,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
368 | } | 405 | } |
369 | 406 | ||
370 | toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { | 407 | toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { |
408 | const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) | ||
371 | const countReportsForVideo = this.get('countReportsForVideo') as number | 409 | const countReportsForVideo = this.get('countReportsForVideo') as number |
372 | const nthReportForVideo = this.get('nthReportForVideo') as number | 410 | const nthReportForVideo = this.get('nthReportForVideo') as number |
373 | const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number | 411 | const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number |
@@ -382,6 +420,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
382 | return { | 420 | return { |
383 | id: this.id, | 421 | id: this.id, |
384 | reason: this.reason, | 422 | reason: this.reason, |
423 | predefinedReasons, | ||
385 | reporterAccount: this.Account.toFormattedJSON(), | 424 | reporterAccount: this.Account.toFormattedJSON(), |
386 | state: { | 425 | state: { |
387 | id: this.state, | 426 | id: this.state, |
@@ -400,6 +439,8 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
400 | }, | 439 | }, |
401 | createdAt: this.createdAt, | 440 | createdAt: this.createdAt, |
402 | updatedAt: this.updatedAt, | 441 | updatedAt: this.updatedAt, |
442 | startAt: this.startAt, | ||
443 | endAt: this.endAt, | ||
403 | count: countReportsForVideo || 0, | 444 | count: countReportsForVideo || 0, |
404 | nth: nthReportForVideo || 0, | 445 | nth: nthReportForVideo || 0, |
405 | countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0), | 446 | countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0), |
@@ -408,14 +449,31 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { | |||
408 | } | 449 | } |
409 | 450 | ||
410 | toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject { | 451 | toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject { |
452 | const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) | ||
453 | |||
454 | const startAt = this.startAt | ||
455 | const endAt = this.endAt | ||
456 | |||
411 | return { | 457 | return { |
412 | type: 'Flag' as 'Flag', | 458 | type: 'Flag' as 'Flag', |
413 | content: this.reason, | 459 | content: this.reason, |
414 | object: this.Video.url | 460 | object: this.Video.url, |
461 | tag: predefinedReasons.map(r => ({ | ||
462 | type: 'Hashtag' as 'Hashtag', | ||
463 | name: r | ||
464 | })), | ||
465 | startAt, | ||
466 | endAt | ||
415 | } | 467 | } |
416 | } | 468 | } |
417 | 469 | ||
418 | private static getStateLabel (id: number) { | 470 | private static getStateLabel (id: number) { |
419 | return VIDEO_ABUSE_STATES[id] || 'Unknown' | 471 | return VIDEO_ABUSE_STATES[id] || 'Unknown' |
420 | } | 472 | } |
473 | |||
474 | private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] { | ||
475 | return (predefinedReasons || []) | ||
476 | .filter(r => r in VideoAbusePredefinedReasons) | ||
477 | .map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString) | ||
478 | } | ||
421 | } | 479 | } |
diff --git a/server/tests/api/check-params/video-abuses.ts b/server/tests/api/check-params/video-abuses.ts index a3fe00ffb..557bf20eb 100644 --- a/server/tests/api/check-params/video-abuses.ts +++ b/server/tests/api/check-params/video-abuses.ts | |||
@@ -20,7 +20,7 @@ import { | |||
20 | checkBadSortPagination, | 20 | checkBadSortPagination, |
21 | checkBadStartPagination | 21 | checkBadStartPagination |
22 | } from '../../../../shared/extra-utils/requests/check-api-params' | 22 | } from '../../../../shared/extra-utils/requests/check-api-params' |
23 | import { VideoAbuseState } from '../../../../shared/models/videos' | 23 | import { VideoAbuseState, VideoAbuseCreate } from '../../../../shared/models/videos' |
24 | 24 | ||
25 | describe('Test video abuses API validators', function () { | 25 | describe('Test video abuses API validators', function () { |
26 | let server: ServerInfo | 26 | let server: ServerInfo |
@@ -132,12 +132,36 @@ describe('Test video abuses API validators', function () { | |||
132 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | 132 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) |
133 | }) | 133 | }) |
134 | 134 | ||
135 | it('Should succeed with the correct parameters', async function () { | 135 | it('Should succeed with the correct parameters (basic)', async function () { |
136 | const fields = { reason: 'super reason' } | 136 | const fields = { reason: 'my super reason' } |
137 | 137 | ||
138 | const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) | 138 | const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) |
139 | videoAbuseId = res.body.videoAbuse.id | 139 | videoAbuseId = res.body.videoAbuse.id |
140 | }) | 140 | }) |
141 | |||
142 | it('Should fail with a wrong predefined reason', async function () { | ||
143 | const fields = { reason: 'my super reason', predefinedReasons: [ 'wrongPredefinedReason' ] } | ||
144 | |||
145 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | ||
146 | }) | ||
147 | |||
148 | it('Should fail with negative timestamps', async function () { | ||
149 | const fields = { reason: 'my super reason', startAt: -1 } | ||
150 | |||
151 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | ||
152 | }) | ||
153 | |||
154 | it('Should fail mith misordered startAt/endAt', async function () { | ||
155 | const fields = { reason: 'my super reason', startAt: 5, endAt: 1 } | ||
156 | |||
157 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) | ||
158 | }) | ||
159 | |||
160 | it('Should succeed with the corret parameters (advanced)', async function () { | ||
161 | const fields: VideoAbuseCreate = { reason: 'my super reason', predefinedReasons: [ 'serverRules' ], startAt: 1, endAt: 5 } | ||
162 | |||
163 | await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) | ||
164 | }) | ||
141 | }) | 165 | }) |
142 | 166 | ||
143 | describe('When updating a video abuse', function () { | 167 | describe('When updating a video abuse', function () { |
diff --git a/server/tests/api/videos/video-abuse.ts b/server/tests/api/videos/video-abuse.ts index a96be97f6..7383bd991 100644 --- a/server/tests/api/videos/video-abuse.ts +++ b/server/tests/api/videos/video-abuse.ts | |||
@@ -2,7 +2,7 @@ | |||
2 | 2 | ||
3 | import * as chai from 'chai' | 3 | import * as chai from 'chai' |
4 | import 'mocha' | 4 | import 'mocha' |
5 | import { VideoAbuse, VideoAbuseState } from '../../../../shared/models/videos' | 5 | import { VideoAbuse, VideoAbuseState, VideoAbusePredefinedReasonsString } from '../../../../shared/models/videos' |
6 | import { | 6 | import { |
7 | cleanupTests, | 7 | cleanupTests, |
8 | deleteVideoAbuse, | 8 | deleteVideoAbuse, |
@@ -291,6 +291,32 @@ describe('Test video abuses', function () { | |||
291 | } | 291 | } |
292 | }) | 292 | }) |
293 | 293 | ||
294 | it('Should list predefined reasons as well as timestamps for the reported video', async function () { | ||
295 | this.timeout(10000) | ||
296 | |||
297 | const reason5 = 'my super bad reason 5' | ||
298 | const predefinedReasons5: VideoAbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ] | ||
299 | const createdAbuse = (await reportVideoAbuse( | ||
300 | servers[0].url, | ||
301 | servers[0].accessToken, | ||
302 | servers[0].video.id, | ||
303 | reason5, | ||
304 | predefinedReasons5, | ||
305 | 1, | ||
306 | 5 | ||
307 | )).body.videoAbuse as VideoAbuse | ||
308 | |||
309 | const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) | ||
310 | |||
311 | { | ||
312 | const abuse = (res.body.data as VideoAbuse[]).find(a => a.id === createdAbuse.id) | ||
313 | expect(abuse.reason).to.equals(reason5) | ||
314 | expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported") | ||
315 | expect(abuse.startAt).to.equal(1, "starting timestamp doesn't match the one reported") | ||
316 | expect(abuse.endAt).to.equal(5, "ending timestamp doesn't match the one reported") | ||
317 | } | ||
318 | }) | ||
319 | |||
294 | it('Should delete the video abuse', async function () { | 320 | it('Should delete the video abuse', async function () { |
295 | this.timeout(10000) | 321 | this.timeout(10000) |
296 | 322 | ||
@@ -307,7 +333,7 @@ describe('Test video abuses', function () { | |||
307 | 333 | ||
308 | { | 334 | { |
309 | const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) | 335 | const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) |
310 | expect(res.body.total).to.equal(5) | 336 | expect(res.body.total).to.equal(6) |
311 | } | 337 | } |
312 | }) | 338 | }) |
313 | 339 | ||
@@ -328,25 +354,28 @@ describe('Test video abuses', function () { | |||
328 | expect(await list({ id: 56 })).to.have.lengthOf(0) | 354 | expect(await list({ id: 56 })).to.have.lengthOf(0) |
329 | expect(await list({ id: 1 })).to.have.lengthOf(1) | 355 | expect(await list({ id: 1 })).to.have.lengthOf(1) |
330 | 356 | ||
331 | expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(3) | 357 | expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(4) |
332 | expect(await list({ search: 'aaaaaaaaaaaaaaaaaaaaaaaaaa' })).to.have.lengthOf(0) | 358 | expect(await list({ search: 'aaaaaaaaaaaaaaaaaaaaaaaaaa' })).to.have.lengthOf(0) |
333 | 359 | ||
334 | expect(await list({ searchVideo: 'my second super name for server 1' })).to.have.lengthOf(1) | 360 | expect(await list({ searchVideo: 'my second super name for server 1' })).to.have.lengthOf(1) |
335 | 361 | ||
336 | expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(3) | 362 | expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(4) |
337 | expect(await list({ searchVideoChannel: 'aaaa' })).to.have.lengthOf(0) | 363 | expect(await list({ searchVideoChannel: 'aaaa' })).to.have.lengthOf(0) |
338 | 364 | ||
339 | expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1) | 365 | expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1) |
340 | expect(await list({ searchReporter: 'root' })).to.have.lengthOf(4) | 366 | expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5) |
341 | 367 | ||
342 | expect(await list({ searchReportee: 'root' })).to.have.lengthOf(3) | 368 | expect(await list({ searchReportee: 'root' })).to.have.lengthOf(4) |
343 | expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0) | 369 | expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0) |
344 | 370 | ||
345 | expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1) | 371 | expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1) |
346 | expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0) | 372 | expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0) |
347 | 373 | ||
348 | expect(await list({ state: VideoAbuseState.ACCEPTED })).to.have.lengthOf(0) | 374 | expect(await list({ state: VideoAbuseState.ACCEPTED })).to.have.lengthOf(0) |
349 | expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(5) | 375 | expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(6) |
376 | |||
377 | expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1) | ||
378 | expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0) | ||
350 | }) | 379 | }) |
351 | 380 | ||
352 | after(async function () { | 381 | after(async function () { |
diff --git a/shared/extra-utils/videos/video-abuses.ts b/shared/extra-utils/videos/video-abuses.ts index 81582bfc7..ff006672a 100644 --- a/shared/extra-utils/videos/video-abuses.ts +++ b/shared/extra-utils/videos/video-abuses.ts | |||
@@ -1,17 +1,26 @@ | |||
1 | import * as request from 'supertest' | 1 | import * as request from 'supertest' |
2 | import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model' | 2 | import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model' |
3 | import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests' | 3 | import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests' |
4 | import { VideoAbuseState } from '@shared/models' | 4 | import { VideoAbuseState, VideoAbusePredefinedReasonsString } from '@shared/models' |
5 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' | 5 | import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' |
6 | 6 | ||
7 | function reportVideoAbuse (url: string, token: string, videoId: number | string, reason: string, specialStatus = 200) { | 7 | function reportVideoAbuse ( |
8 | url: string, | ||
9 | token: string, | ||
10 | videoId: number | string, | ||
11 | reason: string, | ||
12 | predefinedReasons?: VideoAbusePredefinedReasonsString[], | ||
13 | startAt?: number, | ||
14 | endAt?: number, | ||
15 | specialStatus = 200 | ||
16 | ) { | ||
8 | const path = '/api/v1/videos/' + videoId + '/abuse' | 17 | const path = '/api/v1/videos/' + videoId + '/abuse' |
9 | 18 | ||
10 | return request(url) | 19 | return request(url) |
11 | .post(path) | 20 | .post(path) |
12 | .set('Accept', 'application/json') | 21 | .set('Accept', 'application/json') |
13 | .set('Authorization', 'Bearer ' + token) | 22 | .set('Authorization', 'Bearer ' + token) |
14 | .send({ reason }) | 23 | .send({ reason, predefinedReasons, startAt, endAt }) |
15 | .expect(specialStatus) | 24 | .expect(specialStatus) |
16 | } | 25 | } |
17 | 26 | ||
@@ -19,6 +28,7 @@ function getVideoAbusesList (options: { | |||
19 | url: string | 28 | url: string |
20 | token: string | 29 | token: string |
21 | id?: number | 30 | id?: number |
31 | predefinedReason?: VideoAbusePredefinedReasonsString | ||
22 | search?: string | 32 | search?: string |
23 | state?: VideoAbuseState | 33 | state?: VideoAbuseState |
24 | videoIs?: VideoAbuseVideoIs | 34 | videoIs?: VideoAbuseVideoIs |
@@ -31,6 +41,7 @@ function getVideoAbusesList (options: { | |||
31 | url, | 41 | url, |
32 | token, | 42 | token, |
33 | id, | 43 | id, |
44 | predefinedReason, | ||
34 | search, | 45 | search, |
35 | state, | 46 | state, |
36 | videoIs, | 47 | videoIs, |
@@ -44,6 +55,7 @@ function getVideoAbusesList (options: { | |||
44 | const query = { | 55 | const query = { |
45 | sort: 'createdAt', | 56 | sort: 'createdAt', |
46 | id, | 57 | id, |
58 | predefinedReason, | ||
47 | search, | 59 | search, |
48 | state, | 60 | state, |
49 | videoIs, | 61 | videoIs, |
diff --git a/shared/models/activitypub/activity.ts b/shared/models/activitypub/activity.ts index 20ecf176c..31b9e4673 100644 --- a/shared/models/activitypub/activity.ts +++ b/shared/models/activitypub/activity.ts | |||
@@ -1,6 +1,6 @@ | |||
1 | import { ActivityPubActor } from './activitypub-actor' | 1 | import { ActivityPubActor } from './activitypub-actor' |
2 | import { ActivityPubSignature } from './activitypub-signature' | 2 | import { ActivityPubSignature } from './activitypub-signature' |
3 | import { CacheFileObject, VideoTorrentObject } from './objects' | 3 | import { CacheFileObject, VideoTorrentObject, ActivityFlagReasonObject } from './objects' |
4 | import { DislikeObject } from './objects/dislike-object' | 4 | import { DislikeObject } from './objects/dislike-object' |
5 | import { VideoAbuseObject } from './objects/video-abuse-object' | 5 | import { VideoAbuseObject } from './objects/video-abuse-object' |
6 | import { VideoCommentObject } from './objects/video-comment-object' | 6 | import { VideoCommentObject } from './objects/video-comment-object' |
@@ -113,4 +113,7 @@ export interface ActivityFlag extends BaseActivity { | |||
113 | type: 'Flag' | 113 | type: 'Flag' |
114 | content: string | 114 | content: string |
115 | object: APObject | APObject[] | 115 | object: APObject | APObject[] |
116 | tag?: ActivityFlagReasonObject[] | ||
117 | startAt?: number | ||
118 | endAt?: number | ||
116 | } | 119 | } |
diff --git a/shared/models/activitypub/objects/common-objects.ts b/shared/models/activitypub/objects/common-objects.ts index bb3ffe678..096d422ea 100644 --- a/shared/models/activitypub/objects/common-objects.ts +++ b/shared/models/activitypub/objects/common-objects.ts | |||
@@ -1,3 +1,5 @@ | |||
1 | import { VideoAbusePredefinedReasonsString } from '@shared/models/videos' | ||
2 | |||
1 | export interface ActivityIdentifierObject { | 3 | export interface ActivityIdentifierObject { |
2 | identifier: string | 4 | identifier: string |
3 | name: string | 5 | name: string |
@@ -70,17 +72,22 @@ export type ActivityHtmlUrlObject = { | |||
70 | } | 72 | } |
71 | 73 | ||
72 | export interface ActivityHashTagObject { | 74 | export interface ActivityHashTagObject { |
73 | type: 'Hashtag' | 'Mention' | 75 | type: 'Hashtag' |
74 | href?: string | 76 | href?: string |
75 | name: string | 77 | name: string |
76 | } | 78 | } |
77 | 79 | ||
78 | export interface ActivityMentionObject { | 80 | export interface ActivityMentionObject { |
79 | type: 'Hashtag' | 'Mention' | 81 | type: 'Mention' |
80 | href?: string | 82 | href?: string |
81 | name: string | 83 | name: string |
82 | } | 84 | } |
83 | 85 | ||
86 | export interface ActivityFlagReasonObject { | ||
87 | type: 'Hashtag' | ||
88 | name: VideoAbusePredefinedReasonsString | ||
89 | } | ||
90 | |||
84 | export type ActivityTagObject = | 91 | export type ActivityTagObject = |
85 | ActivityPlaylistSegmentHashesObject | 92 | ActivityPlaylistSegmentHashesObject |
86 | | ActivityPlaylistInfohashesObject | 93 | | ActivityPlaylistInfohashesObject |
diff --git a/shared/models/activitypub/objects/video-abuse-object.ts b/shared/models/activitypub/objects/video-abuse-object.ts index d9622b414..73add8ef4 100644 --- a/shared/models/activitypub/objects/video-abuse-object.ts +++ b/shared/models/activitypub/objects/video-abuse-object.ts | |||
@@ -1,5 +1,10 @@ | |||
1 | import { ActivityFlagReasonObject } from './common-objects' | ||
2 | |||
1 | export interface VideoAbuseObject { | 3 | export interface VideoAbuseObject { |
2 | type: 'Flag' | 4 | type: 'Flag' |
3 | content: string | 5 | content: string |
4 | object: string | string[] | 6 | object: string | string[] |
7 | tag?: ActivityFlagReasonObject[] | ||
8 | startAt?: number | ||
9 | endAt?: number | ||
5 | } | 10 | } |
diff --git a/shared/models/videos/abuse/video-abuse-create.model.ts b/shared/models/videos/abuse/video-abuse-create.model.ts index db6458275..c93cb8b2c 100644 --- a/shared/models/videos/abuse/video-abuse-create.model.ts +++ b/shared/models/videos/abuse/video-abuse-create.model.ts | |||
@@ -1,3 +1,8 @@ | |||
1 | import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model' | ||
2 | |||
1 | export interface VideoAbuseCreate { | 3 | export interface VideoAbuseCreate { |
2 | reason: string | 4 | reason: string |
5 | predefinedReasons?: VideoAbusePredefinedReasonsString[] | ||
6 | startAt?: number | ||
7 | endAt?: number | ||
3 | } | 8 | } |
diff --git a/shared/models/videos/abuse/video-abuse-reason.model.ts b/shared/models/videos/abuse/video-abuse-reason.model.ts new file mode 100644 index 000000000..9064f0c1a --- /dev/null +++ b/shared/models/videos/abuse/video-abuse-reason.model.ts | |||
@@ -0,0 +1,33 @@ | |||
1 | export enum VideoAbusePredefinedReasons { | ||
2 | VIOLENT_OR_REPULSIVE = 1, | ||
3 | HATEFUL_OR_ABUSIVE, | ||
4 | SPAM_OR_MISLEADING, | ||
5 | PRIVACY, | ||
6 | RIGHTS, | ||
7 | SERVER_RULES, | ||
8 | THUMBNAILS, | ||
9 | CAPTIONS | ||
10 | } | ||
11 | |||
12 | export type VideoAbusePredefinedReasonsString = | ||
13 | 'violentOrRepulsive' | | ||
14 | 'hatefulOrAbusive' | | ||
15 | 'spamOrMisleading' | | ||
16 | 'privacy' | | ||
17 | 'rights' | | ||
18 | 'serverRules' | | ||
19 | 'thumbnails' | | ||
20 | 'captions' | ||
21 | |||
22 | export const videoAbusePredefinedReasonsMap: { | ||
23 | [key in VideoAbusePredefinedReasonsString]: VideoAbusePredefinedReasons | ||
24 | } = { | ||
25 | violentOrRepulsive: VideoAbusePredefinedReasons.VIOLENT_OR_REPULSIVE, | ||
26 | hatefulOrAbusive: VideoAbusePredefinedReasons.HATEFUL_OR_ABUSIVE, | ||
27 | spamOrMisleading: VideoAbusePredefinedReasons.SPAM_OR_MISLEADING, | ||
28 | privacy: VideoAbusePredefinedReasons.PRIVACY, | ||
29 | rights: VideoAbusePredefinedReasons.RIGHTS, | ||
30 | serverRules: VideoAbusePredefinedReasons.SERVER_RULES, | ||
31 | thumbnails: VideoAbusePredefinedReasons.THUMBNAILS, | ||
32 | captions: VideoAbusePredefinedReasons.CAPTIONS | ||
33 | } | ||
diff --git a/shared/models/videos/abuse/video-abuse.model.ts b/shared/models/videos/abuse/video-abuse.model.ts index f2c2cdc41..38605dcac 100644 --- a/shared/models/videos/abuse/video-abuse.model.ts +++ b/shared/models/videos/abuse/video-abuse.model.ts | |||
@@ -2,10 +2,12 @@ import { Account } from '../../actors/index' | |||
2 | import { VideoConstant } from '../video-constant.model' | 2 | import { VideoConstant } from '../video-constant.model' |
3 | import { VideoAbuseState } from './video-abuse-state.model' | 3 | import { VideoAbuseState } from './video-abuse-state.model' |
4 | import { VideoChannel } from '../channel/video-channel.model' | 4 | import { VideoChannel } from '../channel/video-channel.model' |
5 | import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model' | ||
5 | 6 | ||
6 | export interface VideoAbuse { | 7 | export interface VideoAbuse { |
7 | id: number | 8 | id: number |
8 | reason: string | 9 | reason: string |
10 | predefinedReasons?: VideoAbusePredefinedReasonsString[] | ||
9 | reporterAccount: Account | 11 | reporterAccount: Account |
10 | 12 | ||
11 | state: VideoConstant<VideoAbuseState> | 13 | state: VideoConstant<VideoAbuseState> |
@@ -25,6 +27,9 @@ export interface VideoAbuse { | |||
25 | createdAt: Date | 27 | createdAt: Date |
26 | updatedAt: Date | 28 | updatedAt: Date |
27 | 29 | ||
30 | startAt: number | ||
31 | endAt: number | ||
32 | |||
28 | count?: number | 33 | count?: number |
29 | nth?: number | 34 | nth?: number |
30 | 35 | ||
diff --git a/shared/models/videos/index.ts b/shared/models/videos/index.ts index 51ccb9fbd..58bd1ebd7 100644 --- a/shared/models/videos/index.ts +++ b/shared/models/videos/index.ts | |||
@@ -4,6 +4,7 @@ export * from './rate/account-video-rate.model' | |||
4 | export * from './rate/user-video-rate.type' | 4 | export * from './rate/user-video-rate.type' |
5 | export * from './abuse/video-abuse-state.model' | 5 | export * from './abuse/video-abuse-state.model' |
6 | export * from './abuse/video-abuse-create.model' | 6 | export * from './abuse/video-abuse-create.model' |
7 | export * from './abuse/video-abuse-reason.model' | ||
7 | export * from './abuse/video-abuse.model' | 8 | export * from './abuse/video-abuse.model' |
8 | export * from './abuse/video-abuse-update.model' | 9 | export * from './abuse/video-abuse-update.model' |
9 | export * from './blacklist/video-blacklist.model' | 10 | export * from './blacklist/video-blacklist.model' |
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml index 501187d8f..9434af904 100644 --- a/support/doc/api/openapi.yaml +++ b/support/doc/api/openapi.yaml | |||
@@ -120,7 +120,7 @@ x-tagGroups: | |||
120 | - name: Moderation | 120 | - name: Moderation |
121 | tags: | 121 | tags: |
122 | - Video Abuses | 122 | - Video Abuses |
123 | - Video Blacklist | 123 | - Video Blocks |
124 | - name: Instance Configuration | 124 | - name: Instance Configuration |
125 | tags: | 125 | tags: |
126 | - Config | 126 | - Config |
@@ -1245,6 +1245,7 @@ paths: | |||
1245 | parameters: | 1245 | parameters: |
1246 | - $ref: '#/components/parameters/idOrUUID' | 1246 | - $ref: '#/components/parameters/idOrUUID' |
1247 | requestBody: | 1247 | requestBody: |
1248 | required: true | ||
1248 | content: | 1249 | content: |
1249 | application/json: | 1250 | application/json: |
1250 | schema: | 1251 | schema: |
@@ -1253,6 +1254,28 @@ paths: | |||
1253 | reason: | 1254 | reason: |
1254 | description: Reason why the user reports this video | 1255 | description: Reason why the user reports this video |
1255 | type: string | 1256 | type: string |
1257 | predefinedReasons: | ||
1258 | description: Reason categories that help triage reports | ||
1259 | type: array | ||
1260 | items: | ||
1261 | type: string | ||
1262 | enum: | ||
1263 | - violentOrAbusive | ||
1264 | - hatefulOrAbusive | ||
1265 | - spamOrMisleading | ||
1266 | - privacy | ||
1267 | - rights | ||
1268 | - serverRules | ||
1269 | - thumbnails | ||
1270 | - captions | ||
1271 | startAt: | ||
1272 | type: number | ||
1273 | description: Timestamp in the video that marks the beginning of the report | ||
1274 | endAt: | ||
1275 | type: number | ||
1276 | description: Timestamp in the video that marks the ending of the report | ||
1277 | required: | ||
1278 | - reason | ||
1256 | responses: | 1279 | responses: |
1257 | '204': | 1280 | '204': |
1258 | description: successful operation | 1281 | description: successful operation |
@@ -2488,6 +2511,19 @@ components: | |||
2488 | $ref: '#/components/schemas/VideoAbuseStateSet' | 2511 | $ref: '#/components/schemas/VideoAbuseStateSet' |
2489 | label: | 2512 | label: |
2490 | type: string | 2513 | type: string |
2514 | VideoAbusePredefinedReasons: | ||
2515 | type: array | ||
2516 | items: | ||
2517 | type: string | ||
2518 | enum: | ||
2519 | - violentOrAbusive | ||
2520 | - hatefulOrAbusive | ||
2521 | - spamOrMisleading | ||
2522 | - privacy | ||
2523 | - rights | ||
2524 | - serverRules | ||
2525 | - thumbnails | ||
2526 | - captions | ||
2491 | 2527 | ||
2492 | VideoResolutionConstant: | 2528 | VideoResolutionConstant: |
2493 | properties: | 2529 | properties: |
@@ -2739,6 +2775,8 @@ components: | |||
2739 | type: number | 2775 | type: number |
2740 | reason: | 2776 | reason: |
2741 | type: string | 2777 | type: string |
2778 | predefinedReasons: | ||
2779 | $ref: '#/components/schemas/VideoAbusePredefinedReasons' | ||
2742 | reporterAccount: | 2780 | reporterAccount: |
2743 | $ref: '#/components/schemas/Account' | 2781 | $ref: '#/components/schemas/Account' |
2744 | state: | 2782 | state: |