From 1ebddadd0704812a4600c39cabe2268321e88331 Mon Sep 17 00:00:00 2001 From: Rigel Kent <sendmemail@rigelk.eu> Date: Mon, 22 Jun 2020 13:00:39 +0200 Subject: [PATCH] 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 --- .../moderation/moderation.component.scss | 14 +++ .../video-abuse-details.component.html | 16 +++ .../video-abuse-details.component.ts | 37 +++++- .../video-abuse-list.component.ts | 19 ++-- client/src/app/shared/rest/rest.service.ts | 6 +- .../shared/video-abuse/video-abuse.service.ts | 13 ++- .../video/modals/video-block.component.html | 4 +- .../video/modals/video-report.component.html | 107 ++++++++++++++---- .../video/modals/video-report.component.scss | 17 +++ .../video/modals/video-report.component.ts | 104 +++++++++++++++-- client/src/app/shared/video/video.model.ts | 3 +- client/src/environments/environment.e2e.ts | 3 +- client/src/environments/environment.hmr.ts | 3 +- client/src/environments/environment.prod.ts | 3 +- client/src/environments/environment.ts | 3 +- client/src/sass/include/_mixins.scss | 11 +- client/src/sass/player/peertube-skin.scss | 2 +- server/controllers/api/videos/abuse.ts | 11 +- .../helpers/custom-validators/video-abuses.ts | 29 ++++- server/initializers/constants.ts | 2 +- .../0515-video-abuse-reason-timestamps.ts | 31 +++++ .../lib/activitypub/process/process-flag.ts | 17 ++- .../validators/videos/video-abuses.ts | 39 ++++++- server/models/video/video-abuse.ts | 64 ++++++++++- server/tests/api/check-params/video-abuses.ts | 30 ++++- server/tests/api/videos/video-abuse.ts | 43 +++++-- shared/extra-utils/videos/video-abuses.ts | 18 ++- shared/models/activitypub/activity.ts | 5 +- .../activitypub/objects/common-objects.ts | 11 +- .../activitypub/objects/video-abuse-object.ts | 5 + .../videos/abuse/video-abuse-create.model.ts | 5 + .../videos/abuse/video-abuse-reason.model.ts | 33 ++++++ .../models/videos/abuse/video-abuse.model.ts | 5 + shared/models/videos/index.ts | 1 + support/doc/api/openapi.yaml | 40 ++++++- 35 files changed, 658 insertions(+), 96 deletions(-) create mode 100644 server/initializers/migrations/0515-video-abuse-reason-timestamps.ts create mode 100644 shared/models/videos/abuse/video-abuse-reason.model.ts 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 @@ } } +p-calendar { + display: block; + + ::ng-deep { + .ui-widget-content { + min-width: 400px; + } + + input { + @include peertube-input-text(100%); + } + } +} + .screenratio { div { @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 @@ <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.reasonHtml"></span> </div> + <div *ngIf="getPredefinedReasons()" class="mt-2 d-flex"> + <span class="col-3"></span> + <span class="col-9"> + <a [routerLink]="[ '/admin/moderation/video-abuses/list' ]" [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" *ngFor="let reason of getPredefinedReasons()"> + <div>{{ reason.label }}</div> + </a> + </span> + </div> + + <div *ngIf="videoAbuse.startAt" class="mt-2 d-flex"> + <span class="col-3 moderation-expanded-label" i18n>Reported part</span> + <span class="col-9"> + {{ startAt }}<ng-container *ngIf="videoAbuse.endAt"> - {{ endAt }}</ng-container> + </span> + </div> + <div class="mt-3 d-flex" *ngIf="videoAbuse.moderationComment"> <span class="col-3 moderation-expanded-label" i18n>Note</span> <span class="col-9 moderation-expanded-text" [innerHTML]="videoAbuse.moderationCommentHtml"></span> 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 @@ import { Component, Input } from '@angular/core' -import { Account } from '@app/shared/account/account.model' import { Actor } from '@app/shared/actor/actor.model' +import { VideoAbusePredefinedReasonsString } from '../../../../../../shared/models/videos/abuse/video-abuse-reason.model' import { ProcessedVideoAbuse } from './video-abuse-list.component' +import { I18n } from '@ngx-translate/i18n-polyfill' +import { durationToString } from '@app/shared/misc/utils' @Component({ selector: 'my-video-abuse-details', @@ -11,6 +13,39 @@ import { ProcessedVideoAbuse } from './video-abuse-list.component' export class VideoAbuseDetailsComponent { @Input() videoAbuse: ProcessedVideoAbuse + private predefinedReasonsTranslations: { [key in VideoAbusePredefinedReasonsString]: string } + + constructor ( + private i18n: I18n + ) { + this.predefinedReasonsTranslations = { + violentOrRepulsive: this.i18n('Violent or Repulsive'), + hatefulOrAbusive: this.i18n('Hateful or Abusive'), + spamOrMisleading: this.i18n('Spam or Misleading'), + privacy: this.i18n('Privacy'), + rights: this.i18n('Rights'), + serverRules: this.i18n('Server rules'), + thumbnails: this.i18n('Thumbnails'), + captions: this.i18n('Captions') + } + } + + get startAt () { + return durationToString(this.videoAbuse.startAt) + } + + get endAt () { + return durationToString(this.videoAbuse.endAt) + } + + getPredefinedReasons () { + if (!this.videoAbuse.predefinedReasons) return [] + return this.videoAbuse.predefinedReasons.map(r => ({ + id: r, + label: this.predefinedReasonsTranslations[r] + })) + } + switchToDefaultAvatar ($event: Event) { ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL() } 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 import { Video } from '../../../shared/video/video.model' import { MarkdownService } from '@app/shared/renderer' import { Actor } from '@app/shared/actor/actor.model' -import { buildVideoLink, buildVideoEmbed } from 'src/assets/player/utils' -import { getAbsoluteAPIUrl } from '@app/shared/misc/utils' +import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' import { DomSanitizer } from '@angular/platform-browser' import { BlocklistService } from '@app/shared/blocklist' import { VideoService } from '@app/shared/video/video.service' import { ActivatedRoute, Params, Router } from '@angular/router' import { filter } from 'rxjs/operators' +import { environment } from 'src/environments/environment' export type ProcessedVideoAbuse = VideoAbuse & { moderationCommentHtml?: string, @@ -259,12 +259,15 @@ export class VideoAbuseListComponent extends RestTable implements OnInit, AfterV } getVideoEmbed (videoAbuse: VideoAbuse) { - const absoluteAPIUrl = getAbsoluteAPIUrl() - const embedUrl = buildVideoLink({ - baseUrl: absoluteAPIUrl + '/videos/embed/' + videoAbuse.video.uuid, - warningTitle: false - }) - return buildVideoEmbed(embedUrl) + return buildVideoEmbed( + buildVideoLink({ + baseUrl: `${environment.embedUrl}/videos/embed/${videoAbuse.video.uuid}`, + title: false, + warningTitle: false, + startTime: videoAbuse.startAt, + stopTime: videoAbuse.endAt + }) + ) } 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 { addObjectParams (params: HttpParams, object: { [ name: string ]: any }) { for (const name of Object.keys(object)) { const value = object[name] - if (!value) continue + if (value === undefined || value === null) continue if (Array.isArray(value) && value.length !== 0) { for (const v of value) params = params.append(name, v) @@ -93,7 +93,7 @@ export class RestService { return t }) - .filter(t => !!t) + .filter(t => !!t || t === 0) if (matchedTokens.length === 0) continue @@ -103,7 +103,7 @@ export class RestService { } return { - search: searchTokens.join(' '), + search: searchTokens.join(' ') || undefined, ...additionalFilters } 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' import { Injectable } from '@angular/core' import { SortMeta } from 'primeng/api' import { Observable } from 'rxjs' -import { ResultList, VideoAbuse, VideoAbuseUpdate, VideoAbuseState } from '../../../../../shared' +import { ResultList, VideoAbuse, VideoAbuseCreate, VideoAbuseState, VideoAbuseUpdate } from '../../../../../shared' import { environment } from '../../../environments/environment' import { RestExtractor, RestPagination, RestService } from '../rest' +import { omit } from 'lodash-es' @Injectable() export class VideoAbuseService { @@ -51,7 +52,8 @@ export class VideoAbuseService { } }, searchReporter: { prefix: 'reporter:' }, - searchReportee: { prefix: 'reportee:' } + searchReportee: { prefix: 'reportee:' }, + predefinedReason: { prefix: 'tag:' } }) params = this.restService.addObjectParams(params, filters) @@ -63,9 +65,10 @@ export class VideoAbuseService { ) } - reportVideo (id: number, reason: string) { - const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + id + '/abuse' - const body = { reason } + reportVideo (parameters: { id: number } & VideoAbuseCreate) { + const url = VideoAbuseService.BASE_VIDEO_ABUSE_URL + parameters.id + '/abuse' + + const body = omit(parameters, [ 'id' ]) return this.authHttp.post(url, body) .pipe( 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 @@ <ng-template #modal> <div class="modal-header"> - <h4 i18n class="modal-title">Blocklist video</h4> + <h4 i18n class="modal-title">Block video "{{ video.name }}"</h4> <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> </div> @@ -9,7 +9,7 @@ <form novalidate [formGroup]="form" (ngSubmit)="block()"> <div class="form-group"> <textarea - i18n-placeholder placeholder="Reason..." formControlName="reason" + i18n-placeholder placeholder="Please describe the reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" ></textarea> <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 @@ <ng-template #modal> <div class="modal-header"> - <h4 i18n class="modal-title">Report video</h4> + <h4 i18n class="modal-title">Report video "{{ video.name }}"</h4> <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> </div> <div class="modal-body"> - - <div i18n class="information"> - Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. - </div> - <form novalidate [formGroup]="form" (ngSubmit)="report()"> - <div class="form-group"> - <textarea - i18n-placeholder placeholder="Reason..." formControlName="reason" - [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" - ></textarea> - <div *ngIf="formErrors.reason" class="form-error"> - {{ formErrors.reason }} + + <div class="row"> + <div class="col-5 form-group"> + + <label i18n for="reportPredefinedReasons">What is the issue?</label> + + <div class="ml-2 mt-2 d-flex flex-column"> + <ng-container formGroupName="predefinedReasons"> + <div class="form-group" *ngFor="let reason of predefinedReasons"> + <my-peertube-checkbox formControlName="{{reason.id}}" labelText="{{reason.label}}"> + <ng-template *ngIf="reason.help" ptTemplate="help"> + <div [innerHTML]="reason.help"></div> + </ng-template> + <ng-container *ngIf="reason.description" ngProjectAs="description"> + <div [innerHTML]="reason.description"></div> + </ng-container> + </my-peertube-checkbox> + </div> + </ng-container> + </div> + + </div> + + <div class="col-7"> + <div class="row justify-content-center"> + <div class="col-12 col-lg-9 mb-2"> + <div class="screenratio"> + <div [innerHTML]="embedHtml"></div> + </div> + </div> + </div> + + <div class="mb-1 start-at" formGroupName="timestamp"> + <my-peertube-checkbox + formControlName="hasStart" + i18n-labelText labelText="Start at" + ></my-peertube-checkbox> + + <my-timestamp-input + [timestamp]="timestamp.startAt" + [maxTimestamp]="video.duration" + formControlName="startAt" + inputName="startAt" + > + </my-timestamp-input> + </div> + + <div class="mb-3 stop-at" formGroupName="timestamp" *ngIf="timestamp.hasStart"> + <my-peertube-checkbox + formControlName="hasEnd" + i18n-labelText labelText="Stop at" + ></my-peertube-checkbox> + + <my-timestamp-input + [timestamp]="timestamp.endAt" + [maxTimestamp]="video.duration" + formControlName="endAt" + inputName="endAt" + > + </my-timestamp-input> + </div> + + <div i18n class="information"> + Your report will be sent to moderators of {{ currentHost }}<ng-container *ngIf="isRemoteVideo()"> and will be forwarded to the video origin ({{ originHost }}) too</ng-container>. + </div> + + <div class="form-group"> + <textarea + i18n-placeholder placeholder="Please describe the issue..." formControlName="reason" ngbAutofocus + [ngClass]="{ 'input-error': formErrors['reason'] }" class="form-control" + ></textarea> + <div *ngIf="formErrors.reason" class="form-error"> + {{ formErrors.reason }} + </div> </div> </div> + </div> - <div class="form-group inputs"> - <input - type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" - (click)="hide()" (key.enter)="hide()" - > + <div class="form-group inputs"> + <input + type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel" + (click)="hide()" (key.enter)="hide()" + > + <input type="submit" i18n-value value="Submit" class="action-button-submit" [disabled]="!form.valid"> + </div> - <input - type="submit" i18n-value value="Submit" class="action-button-submit" - [disabled]="!form.valid" - > - </div> </form> - </div> </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 @@ textarea { @include peertube-textarea(100%, 100px); } + +.start-at, +.stop-at { + width: 300px; + display: flex; + align-items: center; + + my-timestamp-input { + margin-left: 10px; + } +} + +.screenratio { + @include large-screen-ratio($selector: 'div, ::ng-deep iframe') { + left: 0; + }; +} 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' import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' import { VideoAbuseService } from '@app/shared/video-abuse' import { Video } from '@app/shared/video/video.model' +import { buildVideoEmbed, buildVideoLink } from 'src/assets/player/utils' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' +import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model' +import { mapValues, pickBy } from 'lodash-es' @Component({ selector: 'my-video-report', @@ -20,6 +24,8 @@ export class VideoReportComponent extends FormReactive implements OnInit { @ViewChild('modal', { static: true }) modal: NgbModal error: string = null + predefinedReasons: { id: VideoAbusePredefinedReasonsString, label: string, description?: string, help?: string }[] = [] + embedHtml: SafeHtml private openedModal: NgbModalRef @@ -29,6 +35,7 @@ export class VideoReportComponent extends FormReactive implements OnInit { private videoAbuseValidatorsService: VideoAbuseValidatorsService, private videoAbuseService: VideoAbuseService, private notifier: Notifier, + private sanitizer: DomSanitizer, private i18n: I18n ) { super() @@ -46,14 +53,82 @@ export class VideoReportComponent extends FormReactive implements OnInit { return '' } + get timestamp () { + return this.form.get('timestamp').value + } + + getVideoEmbed () { + return this.sanitizer.bypassSecurityTrustHtml( + buildVideoEmbed( + buildVideoLink({ + baseUrl: this.video.embedUrl, + title: false, + warningTitle: false + }) + ) + ) + } + ngOnInit () { this.buildForm({ - reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON + reason: this.videoAbuseValidatorsService.VIDEO_ABUSE_REASON, + predefinedReasons: mapValues(videoAbusePredefinedReasonsMap, r => null), + timestamp: { + hasStart: null, + startAt: null, + hasEnd: null, + endAt: null + } }) + + this.predefinedReasons = [ + { + id: 'violentOrRepulsive', + label: this.i18n('Violent or repulsive'), + help: this.i18n('Contains offensive, violent, or coarse language or iconography.') + }, + { + id: 'hatefulOrAbusive', + label: this.i18n('Hateful or abusive'), + help: this.i18n('Contains abusive, racist or sexist language or iconography.') + }, + { + id: 'spamOrMisleading', + label: this.i18n('Spam, ad or false news'), + help: this.i18n('Contains marketing, spam, purposefully deceitful news, or otherwise misleading thumbnail/text/tags. Please provide reputable sources to report hoaxes.') + }, + { + id: 'privacy', + label: this.i18n('Privacy breach or doxxing'), + help: this.i18n('Contains personal information that could be used to track, identify, contact or impersonate someone (e.g. name, address, phone number, email, or credit card details).') + }, + { + id: 'rights', + label: this.i18n('Intellectual property violation'), + help: this.i18n('Infringes my intellectual property or copyright, wrt. the regional rules with which the server must comply.') + }, + { + id: 'serverRules', + label: this.i18n('Breaks server rules'), + description: this.i18n('Anything not included in the above that breaks the terms of service, code of conduct, or general rules in place on the server.') + }, + { + id: 'thumbnails', + label: this.i18n('Thumbnails'), + help: this.i18n('The above can only be seen in thumbnails.') + }, + { + id: 'captions', + label: this.i18n('Captions'), + help: this.i18n('The above can only be seen in captions (please describe which).') + } + ] + + this.embedHtml = this.getVideoEmbed() } show () { - this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false }) + this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false, size: 'lg' }) } hide () { @@ -62,17 +137,24 @@ export class VideoReportComponent extends FormReactive implements OnInit { } report () { - const reason = this.form.value['reason'] + const reason = this.form.get('reason').value + const predefinedReasons = Object.keys(pickBy(this.form.get('predefinedReasons').value)) as VideoAbusePredefinedReasonsString[] + const { hasStart, startAt, hasEnd, endAt } = this.form.get('timestamp').value - this.videoAbuseService.reportVideo(this.video.id, reason) - .subscribe( - () => { - this.notifier.success(this.i18n('Video reported.')) - this.hide() - }, + this.videoAbuseService.reportVideo({ + id: this.video.id, + reason, + predefinedReasons, + startAt: hasStart && startAt ? startAt : undefined, + endAt: hasEnd && endAt ? endAt : undefined + }).subscribe( + () => { + this.notifier.success(this.i18n('Video reported.')) + this.hide() + }, - err => this.notifier.error(err.message) - ) + err => this.notifier.error(err.message) + ) } 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' import { Actor } from '@app/shared/actor/actor.model' import { VideoScheduleUpdate } from '../../../../../shared/models/videos/video-schedule-update.model' import { AuthUser } from '@app/core' +import { environment } from '../../../environments/environment' export class Video implements VideoServerModel { byVideoChannel: string @@ -111,7 +112,7 @@ export class Video implements VideoServerModel { this.previewUrl = hash.previewUrl || (absoluteAPIUrl + hash.previewPath) this.embedPath = hash.embedPath - this.embedUrl = hash.embedUrl || (absoluteAPIUrl + hash.embedPath) + this.embedUrl = hash.embedUrl || (environment.embedUrl + hash.embedPath) this.url = hash.url 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 @@ export const environment = { production: false, hmr: false, - apiUrl: 'http://localhost:9001' + apiUrl: 'http://localhost:9001', + embedUrl: 'http://localhost:9001/videos/embed' } 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 @@ export const environment = { production: false, hmr: true, - apiUrl: '' + apiUrl: '', + embedUrl: 'http://localhost:9000/videos/embed' } 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 @@ export const environment = { production: true, hmr: false, - apiUrl: '' + apiUrl: '', + embedUrl: '/videos/embed' } 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' export const environment = { production: false, hmr: false, - apiUrl: 'http://localhost:9000' + apiUrl: 'http://localhost:9000', + embedUrl: 'http://localhost:9000/videos/embed' } 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 @@ } @mixin chip { + --chip-radius: 5rem; + --chip-padding: .2rem .4rem; $avatar-height: 1.2rem; align-items: center; - border-radius: 5rem; + border-radius: var(--chip-radius); display: inline-flex; font-size: 90%; color: pvar(--mainForegroundColor); @@ -816,12 +818,17 @@ margin: .1rem; max-width: 320px; overflow: hidden; - padding: .2rem .4rem; + padding: var(--chip-padding); text-decoration: none; text-overflow: ellipsis; vertical-align: middle; white-space: nowrap; + &.rectangular { + --chip-radius: .2rem; + --chip-padding: .2rem .3rem; + } + .avatar { margin-left: -.4rem; 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 { } &.focus-visible, &:hover { - background-color: var(--mainColor); + background-color: var(--mainColor, dimgray); } } 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 @@ import * as express from 'express' -import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse } from '../../../../shared' +import { UserRight, VideoAbuseCreate, VideoAbuseState, VideoAbuse, videoAbusePredefinedReasonsMap } from '../../../../shared' import { logger } from '../../../helpers/logger' import { getFormattedObjects } from '../../../helpers/utils' import { sequelizeTypescript } from '../../../initializers/database' @@ -74,6 +74,7 @@ async function listVideoAbuses (req: express.Request, res: express.Response) { count: req.query.count, sort: req.query.sort, id: req.query.id, + predefinedReason: req.query.predefinedReason, search: req.query.search, state: req.query.state, videoIs: req.query.videoIs, @@ -123,12 +124,16 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) { const videoAbuseInstance = await sequelizeTypescript.transaction(async t => { reporterAccount = await AccountModel.load(res.locals.oauth.token.User.Account.id, t) + const predefinedReasons = body.predefinedReasons?.map(r => videoAbusePredefinedReasonsMap[r]) const abuseToCreate = { reporterAccountId: reporterAccount.id, reason: body.reason, videoId: videoInstance.id, - state: VideoAbuseState.PENDING + state: VideoAbuseState.PENDING, + predefinedReasons, + startAt: body.startAt, + endAt: body.endAt } const videoAbuseInstance: MVideoAbuseAccountVideo = await VideoAbuseModel.create(abuseToCreate, { transaction: t }) @@ -152,7 +157,7 @@ async function reportVideoAbuse (req: express.Request, res: express.Response) { reporter: reporterAccount.Actor.getIdentifier() }) - logger.info('Abuse report for video %s created.', videoInstance.name) + logger.info('Abuse report for video "%s" created.', videoInstance.name) return res.json({ videoAbuse: videoAbuseJSON }).end() } 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 @@ import validator from 'validator' import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants' -import { exists } from './misc' +import { exists, isArray } from './misc' import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' +import { VideoAbusePredefinedReasonsString, videoAbusePredefinedReasonsMap } from '@shared/models/videos/abuse/video-abuse-reason.model' const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_ABUSES @@ -10,6 +11,22 @@ function isVideoAbuseReasonValid (value: string) { return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.REASON) } +function isVideoAbusePredefinedReasonValid (value: VideoAbusePredefinedReasonsString) { + return exists(value) && value in videoAbusePredefinedReasonsMap +} + +function isVideoAbusePredefinedReasonsValid (value: VideoAbusePredefinedReasonsString[]) { + return exists(value) && isArray(value) && value.every(v => v in videoAbusePredefinedReasonsMap) +} + +function isVideoAbuseTimestampValid (value: number) { + return value === null || (exists(value) && validator.isInt('' + value, { min: 0 })) +} + +function isVideoAbuseTimestampCoherent (endAt: number, { req }) { + return exists(req.body.startAt) && endAt > req.body.startAt +} + function isVideoAbuseModerationCommentValid (value: string) { return exists(value) && validator.isLength(value, VIDEO_ABUSES_CONSTRAINTS_FIELDS.MODERATION_COMMENT) } @@ -28,8 +45,12 @@ function isAbuseVideoIsValid (value: VideoAbuseVideoIs) { // --------------------------------------------------------------------------- export { - isVideoAbuseStateValid, isVideoAbuseReasonValid, - isAbuseVideoIsValid, - isVideoAbuseModerationCommentValid + isVideoAbusePredefinedReasonValid, + isVideoAbusePredefinedReasonsValid, + isVideoAbuseTimestampValid, + isVideoAbuseTimestampCoherent, + isVideoAbuseModerationCommentValid, + isVideoAbuseStateValid, + isAbuseVideoIsValid } 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' // --------------------------------------------------------------------------- -const LAST_MIGRATION_VERSION = 510 +const LAST_MIGRATION_VERSION = 515 // --------------------------------------------------------------------------- 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 @@ +import * as Sequelize from 'sequelize' + +async function up (utils: { + transaction: Sequelize.Transaction + queryInterface: Sequelize.QueryInterface + sequelize: Sequelize.Sequelize +}): Promise<void> { + await utils.queryInterface.addColumn('videoAbuse', 'predefinedReasons', { + type: Sequelize.ARRAY(Sequelize.INTEGER), + allowNull: true + }) + + await utils.queryInterface.addColumn('videoAbuse', 'startAt', { + type: Sequelize.INTEGER, + allowNull: true + }) + + await utils.queryInterface.addColumn('videoAbuse', 'endAt', { + type: Sequelize.INTEGER, + allowNull: true + }) +} + +function down (options) { + throw new Error('Not implemented.') +} + +export { + up, + down +} 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 @@ -import { ActivityCreate, ActivityFlag, VideoAbuseState } from '../../../../shared' +import { + ActivityCreate, + ActivityFlag, + VideoAbuseState, + videoAbusePredefinedReasonsMap +} from '../../../../shared' import { VideoAbuseObject } from '../../../../shared/models/activitypub/objects' import { retryTransactionWrapper } from '../../../helpers/database-utils' import { logger } from '../../../helpers/logger' @@ -38,13 +43,21 @@ async function processCreateVideoAbuse (activity: ActivityCreate | ActivityFlag, const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: object }) const reporterAccount = await sequelizeTypescript.transaction(async t => AccountModel.load(account.id, t)) + const tags = Array.isArray(flag.tag) ? flag.tag : [] + const predefinedReasons = tags.map(tag => videoAbusePredefinedReasonsMap[tag.name]) + .filter(v => !isNaN(v)) + const startAt = flag.startAt + const endAt = flag.endAt const videoAbuseInstance = await sequelizeTypescript.transaction(async t => { const videoAbuseData = { reporterAccountId: account.id, reason: flag.content, videoId: video.id, - state: VideoAbuseState.PENDING + state: VideoAbuseState.PENDING, + predefinedReasons, + startAt, + endAt } 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 @@ import * as express from 'express' import { body, param, query } from 'express-validator' -import { exists, isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' +import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' import { isAbuseVideoIsValid, isVideoAbuseModerationCommentValid, isVideoAbuseReasonValid, - isVideoAbuseStateValid + isVideoAbuseStateValid, + isVideoAbusePredefinedReasonsValid, + isVideoAbusePredefinedReasonValid, + isVideoAbuseTimestampValid, + isVideoAbuseTimestampCoherent } from '../../../helpers/custom-validators/video-abuses' import { logger } from '../../../helpers/logger' import { doesVideoAbuseExist, doesVideoExist } from '../../../helpers/middlewares' import { areValidationErrors } from '../utils' const videoAbuseReportValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), - body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'), + param('videoId') + .custom(isIdOrUUIDValid) + .not() + .isEmpty() + .withMessage('Should have a valid videoId'), + body('reason') + .custom(isVideoAbuseReasonValid) + .withMessage('Should have a valid reason'), + body('predefinedReasons') + .optional() + .custom(isVideoAbusePredefinedReasonsValid) + .withMessage('Should have a valid list of predefined reasons'), + body('startAt') + .optional() + .customSanitizer(toIntOrNull) + .custom(isVideoAbuseTimestampValid) + .withMessage('Should have valid starting time value'), + body('endAt') + .optional() + .customSanitizer(toIntOrNull) + .custom(isVideoAbuseTimestampValid) + .withMessage('Should have valid ending time value') + .bail() + .custom(isVideoAbuseTimestampCoherent) + .withMessage('Should have a startAt timestamp beginning before endAt'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking videoAbuseReport parameters', { parameters: req.body }) @@ -63,6 +90,10 @@ const videoAbuseListValidator = [ query('id') .optional() .custom(isIdValid).withMessage('Should have a valid id'), + query('predefinedReason') + .optional() + .custom(isVideoAbusePredefinedReasonValid) + .withMessage('Should have a valid predefinedReason'), query('search') .optional() .custom(exists).withMessage('Should have a valid search'), 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 { UpdatedAt } from 'sequelize-typescript' import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' -import { VideoAbuseState, VideoDetails } from '../../../shared' +import { + VideoAbuseState, + VideoDetails, + VideoAbusePredefinedReasons, + VideoAbusePredefinedReasonsString, + videoAbusePredefinedReasonsMap +} from '../../../shared' import { VideoAbuseObject } from '../../../shared/models/activitypub/objects' import { VideoAbuse } from '../../../shared/models/videos' import { @@ -31,6 +37,7 @@ import { ThumbnailModel } from './thumbnail' import { VideoModel } from './video' import { VideoBlacklistModel } from './video-blacklist' import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' +import { invert } from 'lodash' export enum ScopeNames { FOR_API = 'FOR_API' @@ -47,6 +54,7 @@ export enum ScopeNames { // filters id?: number + predefinedReasonId?: number state?: VideoAbuseState videoIs?: VideoAbuseVideoIs @@ -104,6 +112,14 @@ export enum ScopeNames { }) } + if (options.predefinedReasonId) { + Object.assign(where, { + predefinedReasons: { + [Op.contains]: [ options.predefinedReasonId ] + } + }) + } + const onlyBlacklisted = options.videoIs === 'blacklisted' return { @@ -258,6 +274,21 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { @Column(DataType.JSONB) deletedVideo: VideoDetails + @AllowNull(true) + @Default(null) + @Column(DataType.ARRAY(DataType.INTEGER)) + predefinedReasons: VideoAbusePredefinedReasons[] + + @AllowNull(true) + @Default(null) + @Column + startAt: number + + @AllowNull(true) + @Default(null) + @Column + endAt: number + @CreatedAt createdAt: Date @@ -311,6 +342,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { user?: MUserAccountId id?: number + predefinedReason?: VideoAbusePredefinedReasonsString state?: VideoAbuseState videoIs?: VideoAbuseVideoIs @@ -329,6 +361,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { serverAccountId, state, videoIs, + predefinedReason, searchReportee, searchVideo, searchVideoChannel, @@ -337,6 +370,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { } = parameters const userAccountId = user ? user.Account.id : undefined + const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined const query = { offset: start, @@ -348,6 +382,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { const filters = { id, + predefinedReasonId, search, state, videoIs, @@ -360,7 +395,9 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { } return VideoAbuseModel - .scope({ method: [ ScopeNames.FOR_API, filters ] }) + .scope([ + { method: [ ScopeNames.FOR_API, filters ] } + ]) .findAndCountAll(query) .then(({ rows, count }) => { return { total: count, data: rows } @@ -368,6 +405,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { } toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse { + const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) const countReportsForVideo = this.get('countReportsForVideo') as number const nthReportForVideo = this.get('nthReportForVideo') as number const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number @@ -382,6 +420,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { return { id: this.id, reason: this.reason, + predefinedReasons, reporterAccount: this.Account.toFormattedJSON(), state: { id: this.state, @@ -400,6 +439,8 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { }, createdAt: this.createdAt, updatedAt: this.updatedAt, + startAt: this.startAt, + endAt: this.endAt, count: countReportsForVideo || 0, nth: nthReportForVideo || 0, countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0), @@ -408,14 +449,31 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> { } toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject { + const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) + + const startAt = this.startAt + const endAt = this.endAt + return { type: 'Flag' as 'Flag', content: this.reason, - object: this.Video.url + object: this.Video.url, + tag: predefinedReasons.map(r => ({ + type: 'Hashtag' as 'Hashtag', + name: r + })), + startAt, + endAt } } private static getStateLabel (id: number) { return VIDEO_ABUSE_STATES[id] || 'Unknown' } + + private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] { + return (predefinedReasons || []) + .filter(r => r in VideoAbusePredefinedReasons) + .map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString) + } } 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 { checkBadSortPagination, checkBadStartPagination } from '../../../../shared/extra-utils/requests/check-api-params' -import { VideoAbuseState } from '../../../../shared/models/videos' +import { VideoAbuseState, VideoAbuseCreate } from '../../../../shared/models/videos' describe('Test video abuses API validators', function () { let server: ServerInfo @@ -132,12 +132,36 @@ describe('Test video abuses API validators', function () { await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) - it('Should succeed with the correct parameters', async function () { - const fields = { reason: 'super reason' } + it('Should succeed with the correct parameters (basic)', async function () { + const fields = { reason: 'my super reason' } const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) videoAbuseId = res.body.videoAbuse.id }) + + it('Should fail with a wrong predefined reason', async function () { + const fields = { reason: 'my super reason', predefinedReasons: [ 'wrongPredefinedReason' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with negative timestamps', async function () { + const fields = { reason: 'my super reason', startAt: -1 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail mith misordered startAt/endAt', async function () { + const fields = { reason: 'my super reason', startAt: 5, endAt: 1 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should succeed with the corret parameters (advanced)', async function () { + const fields: VideoAbuseCreate = { reason: 'my super reason', predefinedReasons: [ 'serverRules' ], startAt: 1, endAt: 5 } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) + }) }) 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 @@ import * as chai from 'chai' import 'mocha' -import { VideoAbuse, VideoAbuseState } from '../../../../shared/models/videos' +import { VideoAbuse, VideoAbuseState, VideoAbusePredefinedReasonsString } from '../../../../shared/models/videos' import { cleanupTests, deleteVideoAbuse, @@ -291,6 +291,32 @@ describe('Test video abuses', function () { } }) + it('Should list predefined reasons as well as timestamps for the reported video', async function () { + this.timeout(10000) + + const reason5 = 'my super bad reason 5' + const predefinedReasons5: VideoAbusePredefinedReasonsString[] = [ 'violentOrRepulsive', 'captions' ] + const createdAbuse = (await reportVideoAbuse( + servers[0].url, + servers[0].accessToken, + servers[0].video.id, + reason5, + predefinedReasons5, + 1, + 5 + )).body.videoAbuse as VideoAbuse + + const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) + + { + const abuse = (res.body.data as VideoAbuse[]).find(a => a.id === createdAbuse.id) + expect(abuse.reason).to.equals(reason5) + expect(abuse.predefinedReasons).to.deep.equals(predefinedReasons5, "predefined reasons do not match the one reported") + expect(abuse.startAt).to.equal(1, "starting timestamp doesn't match the one reported") + expect(abuse.endAt).to.equal(5, "ending timestamp doesn't match the one reported") + } + }) + it('Should delete the video abuse', async function () { this.timeout(10000) @@ -307,7 +333,7 @@ describe('Test video abuses', function () { { const res = await getVideoAbusesList({ url: servers[0].url, token: servers[0].accessToken }) - expect(res.body.total).to.equal(5) + expect(res.body.total).to.equal(6) } }) @@ -328,25 +354,28 @@ describe('Test video abuses', function () { expect(await list({ id: 56 })).to.have.lengthOf(0) expect(await list({ id: 1 })).to.have.lengthOf(1) - expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(3) + expect(await list({ search: 'my super name for server 1' })).to.have.lengthOf(4) expect(await list({ search: 'aaaaaaaaaaaaaaaaaaaaaaaaaa' })).to.have.lengthOf(0) expect(await list({ searchVideo: 'my second super name for server 1' })).to.have.lengthOf(1) - expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(3) + expect(await list({ searchVideoChannel: 'root' })).to.have.lengthOf(4) expect(await list({ searchVideoChannel: 'aaaa' })).to.have.lengthOf(0) expect(await list({ searchReporter: 'user2' })).to.have.lengthOf(1) - expect(await list({ searchReporter: 'root' })).to.have.lengthOf(4) + expect(await list({ searchReporter: 'root' })).to.have.lengthOf(5) - expect(await list({ searchReportee: 'root' })).to.have.lengthOf(3) + expect(await list({ searchReportee: 'root' })).to.have.lengthOf(4) expect(await list({ searchReportee: 'aaaa' })).to.have.lengthOf(0) expect(await list({ videoIs: 'deleted' })).to.have.lengthOf(1) expect(await list({ videoIs: 'blacklisted' })).to.have.lengthOf(0) expect(await list({ state: VideoAbuseState.ACCEPTED })).to.have.lengthOf(0) - expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(5) + expect(await list({ state: VideoAbuseState.PENDING })).to.have.lengthOf(6) + + expect(await list({ predefinedReason: 'violentOrRepulsive' })).to.have.lengthOf(1) + expect(await list({ predefinedReason: 'serverRules' })).to.have.lengthOf(0) }) 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 @@ import * as request from 'supertest' import { VideoAbuseUpdate } from '../../models/videos/abuse/video-abuse-update.model' import { makeDeleteRequest, makePutBodyRequest, makeGetRequest } from '../requests/requests' -import { VideoAbuseState } from '@shared/models' +import { VideoAbuseState, VideoAbusePredefinedReasonsString } from '@shared/models' import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type' -function reportVideoAbuse (url: string, token: string, videoId: number | string, reason: string, specialStatus = 200) { +function reportVideoAbuse ( + url: string, + token: string, + videoId: number | string, + reason: string, + predefinedReasons?: VideoAbusePredefinedReasonsString[], + startAt?: number, + endAt?: number, + specialStatus = 200 +) { const path = '/api/v1/videos/' + videoId + '/abuse' return request(url) .post(path) .set('Accept', 'application/json') .set('Authorization', 'Bearer ' + token) - .send({ reason }) + .send({ reason, predefinedReasons, startAt, endAt }) .expect(specialStatus) } @@ -19,6 +28,7 @@ function getVideoAbusesList (options: { url: string token: string id?: number + predefinedReason?: VideoAbusePredefinedReasonsString search?: string state?: VideoAbuseState videoIs?: VideoAbuseVideoIs @@ -31,6 +41,7 @@ function getVideoAbusesList (options: { url, token, id, + predefinedReason, search, state, videoIs, @@ -44,6 +55,7 @@ function getVideoAbusesList (options: { const query = { sort: 'createdAt', id, + predefinedReason, search, state, 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 @@ import { ActivityPubActor } from './activitypub-actor' import { ActivityPubSignature } from './activitypub-signature' -import { CacheFileObject, VideoTorrentObject } from './objects' +import { CacheFileObject, VideoTorrentObject, ActivityFlagReasonObject } from './objects' import { DislikeObject } from './objects/dislike-object' import { VideoAbuseObject } from './objects/video-abuse-object' import { VideoCommentObject } from './objects/video-comment-object' @@ -113,4 +113,7 @@ export interface ActivityFlag extends BaseActivity { type: 'Flag' content: string object: APObject | APObject[] + tag?: ActivityFlagReasonObject[] + startAt?: number + endAt?: number } 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 @@ +import { VideoAbusePredefinedReasonsString } from '@shared/models/videos' + export interface ActivityIdentifierObject { identifier: string name: string @@ -70,17 +72,22 @@ export type ActivityHtmlUrlObject = { } export interface ActivityHashTagObject { - type: 'Hashtag' | 'Mention' + type: 'Hashtag' href?: string name: string } export interface ActivityMentionObject { - type: 'Hashtag' | 'Mention' + type: 'Mention' href?: string name: string } +export interface ActivityFlagReasonObject { + type: 'Hashtag' + name: VideoAbusePredefinedReasonsString +} + export type ActivityTagObject = ActivityPlaylistSegmentHashesObject | 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 @@ +import { ActivityFlagReasonObject } from './common-objects' + export interface VideoAbuseObject { type: 'Flag' content: string object: string | string[] + tag?: ActivityFlagReasonObject[] + startAt?: number + endAt?: number } 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 @@ +import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model' + export interface VideoAbuseCreate { reason: string + predefinedReasons?: VideoAbusePredefinedReasonsString[] + startAt?: number + endAt?: number } 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 @@ +export enum VideoAbusePredefinedReasons { + VIOLENT_OR_REPULSIVE = 1, + HATEFUL_OR_ABUSIVE, + SPAM_OR_MISLEADING, + PRIVACY, + RIGHTS, + SERVER_RULES, + THUMBNAILS, + CAPTIONS +} + +export type VideoAbusePredefinedReasonsString = + 'violentOrRepulsive' | + 'hatefulOrAbusive' | + 'spamOrMisleading' | + 'privacy' | + 'rights' | + 'serverRules' | + 'thumbnails' | + 'captions' + +export const videoAbusePredefinedReasonsMap: { + [key in VideoAbusePredefinedReasonsString]: VideoAbusePredefinedReasons +} = { + violentOrRepulsive: VideoAbusePredefinedReasons.VIOLENT_OR_REPULSIVE, + hatefulOrAbusive: VideoAbusePredefinedReasons.HATEFUL_OR_ABUSIVE, + spamOrMisleading: VideoAbusePredefinedReasons.SPAM_OR_MISLEADING, + privacy: VideoAbusePredefinedReasons.PRIVACY, + rights: VideoAbusePredefinedReasons.RIGHTS, + serverRules: VideoAbusePredefinedReasons.SERVER_RULES, + thumbnails: VideoAbusePredefinedReasons.THUMBNAILS, + captions: VideoAbusePredefinedReasons.CAPTIONS +} 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' import { VideoConstant } from '../video-constant.model' import { VideoAbuseState } from './video-abuse-state.model' import { VideoChannel } from '../channel/video-channel.model' +import { VideoAbusePredefinedReasonsString } from './video-abuse-reason.model' export interface VideoAbuse { id: number reason: string + predefinedReasons?: VideoAbusePredefinedReasonsString[] reporterAccount: Account state: VideoConstant<VideoAbuseState> @@ -25,6 +27,9 @@ export interface VideoAbuse { createdAt: Date updatedAt: Date + startAt: number + endAt: number + count?: number nth?: number 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' export * from './rate/user-video-rate.type' export * from './abuse/video-abuse-state.model' export * from './abuse/video-abuse-create.model' +export * from './abuse/video-abuse-reason.model' export * from './abuse/video-abuse.model' export * from './abuse/video-abuse-update.model' 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: - name: Moderation tags: - Video Abuses - - Video Blacklist + - Video Blocks - name: Instance Configuration tags: - Config @@ -1245,6 +1245,7 @@ paths: parameters: - $ref: '#/components/parameters/idOrUUID' requestBody: + required: true content: application/json: schema: @@ -1253,6 +1254,28 @@ paths: reason: description: Reason why the user reports this video type: string + predefinedReasons: + description: Reason categories that help triage reports + type: array + items: + type: string + enum: + - violentOrAbusive + - hatefulOrAbusive + - spamOrMisleading + - privacy + - rights + - serverRules + - thumbnails + - captions + startAt: + type: number + description: Timestamp in the video that marks the beginning of the report + endAt: + type: number + description: Timestamp in the video that marks the ending of the report + required: + - reason responses: '204': description: successful operation @@ -2488,6 +2511,19 @@ components: $ref: '#/components/schemas/VideoAbuseStateSet' label: type: string + VideoAbusePredefinedReasons: + type: array + items: + type: string + enum: + - violentOrAbusive + - hatefulOrAbusive + - spamOrMisleading + - privacy + - rights + - serverRules + - thumbnails + - captions VideoResolutionConstant: properties: @@ -2739,6 +2775,8 @@ components: type: number reason: type: string + predefinedReasons: + $ref: '#/components/schemas/VideoAbusePredefinedReasons' reporterAccount: $ref: '#/components/schemas/Account' state: