+
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 4713660a226..b2606cbd825 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 988fa03d474..c2d441bba29 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 16e43cbd823..dc5f4562674 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 7c00e8d4f9b..7724d27c929 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 853e2080386..72eed45e5e6 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 d5dfe55736a..368aa138990 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 b6bc784b53c..60f5d945098 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 eb80ea0e32b..6a1deac7677 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 1fc744e674a..bdeff8f9a45 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 77843f149d9..ab207445950 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 05e11b1c69c..0c2c342681e 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 314f094b3ac..dd79c0e168a 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 00000000000..c583356171d
--- /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
{
+ 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 8d1c9c869e9..1d7132a3a70 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 901997bcb2d..5bbd1e3c60e 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 b2f11133726..1319332f073 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 {
@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 {
user?: MUserAccountId
id?: number
+ predefinedReason?: VideoAbusePredefinedReasonsString
state?: VideoAbuseState
videoIs?: VideoAbuseVideoIs
@@ -329,6 +361,7 @@ export class VideoAbuseModel extends Model {
serverAccountId,
state,
videoIs,
+ predefinedReason,
searchReportee,
searchVideo,
searchVideoChannel,
@@ -337,6 +370,7 @@ export class VideoAbuseModel extends Model {
} = 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 {
const filters = {
id,
+ predefinedReasonId,
search,
state,
videoIs,
@@ -360,7 +395,9 @@ export class VideoAbuseModel extends Model {
}
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 {
}
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 {
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 {
},
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 {
}
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 a3fe00ffbdf..557bf20eb41 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 a96be97f636..7383bd991c5 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 81582bfc7fd..ff006672ad9 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 20ecf176c60..31b9e467397 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 bb3ffe6785e..096d422eab1 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 d9622b41489..73add8ef447 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 db64582755f..c93cb8b2c42 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 00000000000..9064f0c1ae1
--- /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 f2c2cdc415d..38605dcac05 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
@@ -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 51ccb9fbd0e..58bd1ebd7df 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 501187d8fb4..9434af90490 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: