aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRigel Kent <sendmemail@rigelk.eu>2020-06-22 13:00:39 +0200
committerGitHub <noreply@github.com>2020-06-22 13:00:39 +0200
commit1ebddadd0704812a4600c39cabe2268321e88331 (patch)
tree1cc8560e5b63e9976aa5411ba800a62cfe7b8ea9
parent07aea1a2642fc9868cb01e30c322514029d5b95a (diff)
downloadPeerTube-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
-rw-r--r--client/src/app/+admin/moderation/moderation.component.scss14
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.html16
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-details.component.ts37
-rw-r--r--client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.ts19
-rw-r--r--client/src/app/shared/rest/rest.service.ts6
-rw-r--r--client/src/app/shared/video-abuse/video-abuse.service.ts13
-rw-r--r--client/src/app/shared/video/modals/video-block.component.html4
-rw-r--r--client/src/app/shared/video/modals/video-report.component.html105
-rw-r--r--client/src/app/shared/video/modals/video-report.component.scss17
-rw-r--r--client/src/app/shared/video/modals/video-report.component.ts104
-rw-r--r--client/src/app/shared/video/video.model.ts3
-rw-r--r--client/src/environments/environment.e2e.ts3
-rw-r--r--client/src/environments/environment.hmr.ts3
-rw-r--r--client/src/environments/environment.prod.ts3
-rw-r--r--client/src/environments/environment.ts3
-rw-r--r--client/src/sass/include/_mixins.scss11
-rw-r--r--client/src/sass/player/peertube-skin.scss2
-rw-r--r--server/controllers/api/videos/abuse.ts11
-rw-r--r--server/helpers/custom-validators/video-abuses.ts29
-rw-r--r--server/initializers/constants.ts2
-rw-r--r--server/initializers/migrations/0515-video-abuse-reason-timestamps.ts31
-rw-r--r--server/lib/activitypub/process/process-flag.ts17
-rw-r--r--server/middlewares/validators/videos/video-abuses.ts39
-rw-r--r--server/models/video/video-abuse.ts64
-rw-r--r--server/tests/api/check-params/video-abuses.ts30
-rw-r--r--server/tests/api/videos/video-abuse.ts43
-rw-r--r--shared/extra-utils/videos/video-abuses.ts18
-rw-r--r--shared/models/activitypub/activity.ts5
-rw-r--r--shared/models/activitypub/objects/common-objects.ts11
-rw-r--r--shared/models/activitypub/objects/video-abuse-object.ts5
-rw-r--r--shared/models/videos/abuse/video-abuse-create.model.ts5
-rw-r--r--shared/models/videos/abuse/video-abuse-reason.model.ts33
-rw-r--r--shared/models/videos/abuse/video-abuse.model.ts5
-rw-r--r--shared/models/videos/index.ts1
-rw-r--r--support/doc/api/openapi.yaml40
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
45p-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 @@
1import { Component, Input } from '@angular/core' 1import { Component, Input } from '@angular/core'
2import { Account } from '@app/shared/account/account.model'
3import { Actor } from '@app/shared/actor/actor.model' 2import { Actor } from '@app/shared/actor/actor.model'
3import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model'
4import { ProcessedVideoAbuse } from './video-abuse-list.component' 4import { ProcessedVideoAbuse } from './video-abuse-list.component'
5import { I18n } from '@ngx-translate/i18n-polyfill'
6import { 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'
11export class VideoAbuseDetailsComponent { 13export 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
11import { Video } from '../../../shared/video/video.model' 11import { Video } from '../../../shared/video/video.model'
12import { MarkdownService } from '@app/shared/renderer' 12import { MarkdownService } from '@app/shared/renderer'
13import { Actor } from '@app/shared/actor/actor.model' 13import { Actor } from '@app/shared/actor/actor.model'
14import { buildVideoLink, buildVideoEmbed } from 'src/assets/player/utils' 14import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
15import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
16import { DomSanitizer } from '@angular/platform-browser' 15import { DomSanitizer } from '@angular/platform-browser'
17import { BlocklistService } from '@app/shared/blocklist' 16import { BlocklistService } from '@app/shared/blocklist'
18import { VideoService } from '@app/shared/video/video.service' 17import { VideoService } from '@app/shared/video/video.service'
19import { ActivatedRoute, Params, Router } from '@angular/router' 18import { ActivatedRoute, Params, Router } from '@angular/router'
20import { filter } from 'rxjs/operators' 19import { filter } from 'rxjs/operators'
20import { environment } from 'src/environments/environment'
21 21
22export type ProcessedVideoAbuse = VideoAbuse & { 22export 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'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { SortMeta } from 'primeng/api' 4import { SortMeta } from 'primeng/api'
5import { Observable } from 'rxjs' 5import { Observable } from 'rxjs'
6import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared' 6import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared'
7import { environment } from '../../../environments/environment' 7import { environment } from '../../../environments/environment'
8import { RestExtractor, RestPagination, RestService } from '../rest' 8import { RestExtractor, RestPagination, RestService } from '../rest'
9import { omit } from 'lodash-es'
9 10
10@Injectable() 11@Injectable()
11export class VideoAbuseService { 12export 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 @@
8textarea { 8textarea {
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'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { VideoAbuseService } from '@app/shared/video-abuse' 9import { VideoAbuseService } from '@app/shared/video-abuse'
10import { Video } from '@app/shared/video/video.model' 10import { Video } from '@app/shared/video/video.model'
11import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils'
12import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
13import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
14import { 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'
7import { Actor } from '@app/shared/actor/actor.model' 7import { Actor } from '@app/shared/actor/actor.model'
8import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' 8import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model'
9import { AuthUser } from '@app/core' 9import { AuthUser } from '@app/core'
10import { environment } from '../../../environments/environment'
10 11
11export class Video implements VideoServerModel { 12export 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 @@
1export const environment = { 1export 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 @@
1export const environment = { 1export 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 @@
1export const environment = { 1export 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'
11export const environment = { 11export 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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse } from '../../../../shared' 2import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse, videoAbusePredefinedReasonsMap } from '../../../../shared'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { getFormattedObjects } from '../../../helpers/utils' 4import { getFormattedObjects } from '../../../helpers/utils'
5import { sequelizeTypescript } from '../../../initializers/database' 5import { 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 @@
1import validator from 'validator' 1import validator from 'validator'
2 2
3import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' 3import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
4import { exists } from './misc' 4import { exists, isArray } from './misc'
5import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' 5import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
6import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model'
6 7
7const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES 8const 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
14function isVideoAbusePredefinedReasonValid (value: VideoAbusePredefinedReasonsString) {
15 return exists(value) && value in videoAbusePredefinedReasonsMap
16}
17
18function isVideoAbusePredefinedReasonsValid (value: VideoAbusePredefinedReasonsString[]) {
19 return exists(value) && isArray(value) && value.every(v => v in videoAbusePredefinedReasonsMap)
20}
21
22function isVideoAbuseTimestampValid (value: number) {
23 return value === null || (exists(value) && validator.isInt('' + value, { min: 0 }))
24}
25
26function isVideoAbuseTimestampCoherent (endAt: number, { req }) {
27 return exists(req.body.startAt) && endAt > req.body.startAt
28}
29
13function isVideoAbuseModerationCommentValid (value: string) { 30function 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
30export { 47export {
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
17const LAST_MIGRATION_VERSION = 510 17const 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 @@
1import * as Sequelize from 'sequelize'
2
3async 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
24function down (options) {
25 throw new Error('Not implemented.')
26}
27
28export {
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 @@
1import { ActivityCreate, ActivityFlag, VideoAbuseState } from '../../../../shared' 1import {
2 ActivityCreate,
3 ActivityFlag,
4 VideoAbuseState,
5 videoAbusePredefinedReasonsMap
6} from '../../../../shared'
2import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects' 7import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects'
3import { retryTransactionWrapper } from '../../../helpers/database-utils' 8import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { logger } from '../../../helpers/logger' 9import { 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 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { exists, isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' 3import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
4import { 4import {
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'
10import { logger } from '../../../helpers/logger' 14import { logger } from '../../../helpers/logger'
11import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares' 15import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares'
12import { areValidationErrors } from '../utils' 16import { areValidationErrors } from '../utils'
13 17
14const videoAbuseReportValidator = [ 18const 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'
17import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' 17import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
18import { VideoAbuseState, VideoDetails } from '../../../shared' 18import {
19 VideoAbuseState,
20 VideoDetails,
21 VideoAbusePredefinedReasons,
22 VideoAbusePredefinedReasonsString,
23 videoAbusePredefinedReasonsMap
24} from '../../../shared'
19import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' 25import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
20import { VideoAbuse } from '../../../shared/models/videos' 26import { VideoAbuse } from '../../../shared/models/videos'
21import { 27import {
@@ -31,6 +37,7 @@ import { ThumbnailModel } from './thumbnail'
31import { VideoModel } from './video' 37import { VideoModel } from './video'
32import { VideoBlacklistModel } from './video-blacklist' 38import { VideoBlacklistModel } from './video-blacklist'
33import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 39import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
40import { invert } from 'lodash'
34 41
35export enum ScopeNames { 42export 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'
23import { VideoAbuseState } from '../../../../shared/models/videos' 23import { VideoAbuseState, VideoAbuseCreate } from '../../../../shared/models/videos'
24 24
25describe('Test video abuses API validators', function () { 25describe('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
3import * as chai from 'chai' 3import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { VideoAbuse, VideoAbuseState } from '../../../../shared/models/videos' 5import { VideoAbuse, VideoAbuseState, VideoAbusePredefinedReasonsString } from '../../../../shared/models/videos'
6import { 6import {
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 @@
1import * as request from 'supertest' 1import * as request from 'supertest'
2import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model' 2import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model'
3import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests' 3import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests'
4import { VideoAbuseState } from '@shared/models' 4import { VideoAbuseState, VideoAbusePredefinedReasonsString } from '@shared/models'
5import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' 5import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
6 6
7function reportVideoAbuse (url: string, token: string, videoId: number | string, reason: string, specialStatus = 200) { 7function 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 @@
1import { ActivityPubActor } from './activitypub-actor' 1import { ActivityPubActor } from './activitypub-actor'
2import { ActivityPubSignature } from './activitypub-signature' 2import { ActivityPubSignature } from './activitypub-signature'
3import { CacheFileObject, VideoTorrentObject } from './objects' 3import { CacheFileObject, VideoTorrentObject, ActivityFlagReasonObject } from './objects'
4import { DislikeObject } from './objects/dislike-object' 4import { DislikeObject } from './objects/dislike-object'
5import { VideoAbuseObject } from './objects/video-abuse-object' 5import { VideoAbuseObject } from './objects/video-abuse-object'
6import { VideoCommentObject } from './objects/video-comment-object' 6import { 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 @@
1import { VideoAbusePredefinedReasonsString } from '@shared/models/videos'
2
1export interface ActivityIdentifierObject { 3export 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
72export interface ActivityHashTagObject { 74export 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
78export interface ActivityMentionObject { 80export 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
86export interface ActivityFlagReasonObject {
87 type: 'Hashtag'
88 name: VideoAbusePredefinedReasonsString
89}
90
84export type ActivityTagObject = 91export 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 @@
1import { ActivityFlagReasonObject } from './common-objects'
2
1export interface VideoAbuseObject { 3export 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 @@
1import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
2
1export interface VideoAbuseCreate { 3export 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 @@
1export 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
12export type VideoAbusePredefinedReasonsString =
13 'violentOrRepulsive' |
14 'hatefulOrAbusive' |
15 'spamOrMisleading' |
16 'privacy' |
17 'rights' |
18 'serverRules' |
19 'thumbnails' |
20 'captions'
21
22export 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'
2import { VideoConstant } from '../video-constant.model' 2import { VideoConstant } from '../video-constant.model'
3import { VideoAbuseState } from './video-abuse-state.model' 3import { VideoAbuseState } from './video-abuse-state.model'
4import { VideoChannel } from '../channel/video-channel.model' 4import { VideoChannel } from '../channel/video-channel.model'
5import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model'
5 6
6export interface VideoAbuse { 7export 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'
4export * from './rate/user-video-rate.type' 4export * from './rate/user-video-rate.type'
5export * from './abuse/video-abuse-state.model' 5export * from './abuse/video-abuse-state.model'
6export * from './abuse/video-abuse-create.model' 6export * from './abuse/video-abuse-create.model'
7export * from './abuse/video-abuse-reason.model'
7export * from './abuse/video-abuse.model' 8export * from './abuse/video-abuse.model'
8export * from './abuse/video-abuse-update.model' 9export * from './abuse/video-abuse-update.model'
9export * from './blacklist/video-blacklist.model' 10export * 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: