Make sure a report doesn't get deleted upon the deletion of its video
authorRigel Kent <sendmemail@rigelk.eu>
Thu, 16 Apr 2020 12:22:27 +0000 (14:22 +0200)
committerRigel Kent <par@rigelk.eu>
Fri, 1 May 2020 14:41:02 +0000 (16:41 +0200)
15 files changed:
client/src/app/+admin/follows/followers-list/followers-list.component.html
client/src/app/+admin/follows/following-list/following-list.component.html
client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.html
client/src/app/+admin/moderation/instance-blocklist/instance-account-blocklist.component.html
client/src/app/+admin/moderation/instance-blocklist/instance-server-blocklist.component.html
client/src/app/+admin/moderation/moderation.component.scss
client/src/app/+admin/moderation/video-abuse-list/video-abuse-list.component.html
server/helpers/middlewares/video-abuses.ts
server/initializers/constants.ts
server/initializers/migrations/0490-abuse-video.ts [new file with mode: 0644]
server/middlewares/validators/videos/video-abuses.ts
server/models/video/video-abuse.ts
server/models/video/video.ts
server/typings/models/video/video-abuse.ts
shared/models/videos/abuse/video-abuse.model.ts

index c532b5f323c03ca1c98b659232789911cbfbd5c2..7455cdf2b27d5d1ce47e6fe6fbc7e9195c19a638 100644 (file)
   <ng-template pTemplate="header">
     <tr>
       <th i18n>Follower handle</th>
-      <th i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
-      <th i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
-      <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th></th>
+      <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
+      <th style="width: 100px;" i18n pSortableColumn="score">Score <p-sortIcon field="score"></p-sortIcon></th>
+      <th style="width: 200px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
+      <th style="width: 100px;"></th>
     </tr>
   </ng-template>
 
index cb62d52ddba0470bc4104977f2e383fac79f98e5..f3bb7216b28bcf5db1b5faa151d2d772cdddacc0 100644 (file)
   <ng-template pTemplate="header">
     <tr>
       <th i18n>Host</th>
-      <th i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
-      <th i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
-      <th></th>
+      <th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
+      <th style="width: 200px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
+      <th style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
+      <th style="width: 100px;"></th>
     </tr>
   </ng-template>
 
index 07362b3b93d4f58a0155652aebe6b00f728ce8fa..a8dcc69d2a2a67e1e9269b2b34eea6317d64a282 100644 (file)
 >
   <ng-template pTemplate="header">
     <tr>
-      <th style="width: 40px"></th>
-      <th i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
+      <th style="width: 40px;"></th>
+      <th style="width: 160px;" i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
       <th i18n pSortableColumn="name">Video name <p-sortIcon field="name"></p-sortIcon></th>
       <th i18n>Video URL</th>
-      <th i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
+      <th style="width: 100px;" i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
       <th style="width: 80px;"></th>
     </tr>
   </ng-template>
index 2efdd2bc37045eeb1ccae754f8c00273891833ca..e40c29abf9908c3330ab2f32914ad1fb3183d559 100644 (file)
@@ -8,8 +8,8 @@
   <ng-template pTemplate="header">
     <tr>
       <th i18n>Account</th>
-      <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th></th> <!-- column for action buttons -->
+      <th style="width: 200px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
+      <th style="width: 100px;"></th> <!-- column for action buttons --> 
     </tr>
   </ng-template>
 
index cec703289797492bedc5b5e1f5f55e2ffd643def..bf5c0091895851437663bf72ba6141b6e04ac85f 100644 (file)
@@ -16,8 +16,8 @@
   <ng-template pTemplate="header">
     <tr>
       <th i18n>Instance</th>
-      <th i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
-      <th></th> <!-- column for action buttons -->
+      <th style="width: 200px;" i18n pSortableColumn="createdAt">Muted at <p-sortIcon field="createdAt"></p-sortIcon></th>
+      <th style="width: 100px;"></th> <!-- column for action buttons -->
     </tr>
   </ng-template>
 
index 89e9b47d381b67a2ee076975042967f0adf31594..9af76d2ddcf1570e87d08657bb9467656f26dbed 100644 (file)
@@ -1,5 +1,6 @@
 @import 'variables';
 @import 'mixins';
+@import 'miniature';
 
 .form-sub-title {
   flex-grow: 0;
   }
 }
 
+.video-abuse-states {
+  & > :not(:first-child) {
+    margin-left: .4rem;
+  }
+}
+
 .screenratio {
   position: relative;
   width: 100%;
   height: 0;
   padding-bottom: 56%;
 
+  div {
+    @include miniature-thumbnail;
+    position: absolute;
+    height: 100%;
+    width: 100%;
+    display: inline-flex;
+    justify-content: center;
+    align-items: center;
+  }
+
   ::ng-deep iframe {
     position: absolute;
     width: 100% !important;
index 4ecb395f8d1e70f0b530f5321ad9d505c55bc9da..3d356dc7ce807a1a413d30bc14f07333c089d282 100644 (file)
         </a>
       </td>
 
-      <td class="c-hand" [pRowToggler]="videoAbuse">
+      <td class="c-hand video-abuse-states" [pRowToggler]="videoAbuse">
         <span *ngIf="isVideoAbuseAccepted(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-ok"></span>
         <span *ngIf="isVideoAbuseRejected(videoAbuse)" [title]="videoAbuse.state.label" class="glyphicon glyphicon-remove"></span>
+        <span *ngIf="videoAbuse.moderationComment" [title]="videoAbuse.moderationComment" class="glyphicon glyphicon-comment"></span>
       </td>
 
       <td class="action-cell">
             </div>
 
             <div class="col-4">
-              <div class="screenratio" [innerHTML]="videoAbuse.embedHtml"></div>
+              <div class="screenratio">
+                <div *ngIf="videoAbuse.video.deleted">
+                  <span i18n>The video was {{ videoAbuse.video.deleted ? 'deleted' : 'blacklisted' }}</span>
+                </div>
+                <div *ngIf="!videoAbuse.video.deleted" [innerHTML]="videoAbuse.embedHtml"></div>
+              </div>
             </div>
           </div>
         </td>
index 8a1d3d618296e38dece030bcf18282e2a9e21c1e..7553a5eb3355e445606ad8bcbc2cc16a76c1494f 100644 (file)
@@ -1,9 +1,17 @@
 import { Response } from 'express'
 import { VideoAbuseModel } from '../../models/video/video-abuse'
+import { fetchVideo } from '../video'
 
-async function doesVideoAbuseExist (abuseIdArg: number | string, videoId: number, res: Response) {
+async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) {
   const abuseId = parseInt(abuseIdArg + '', 10)
-  const videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, videoId)
+  let videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, null, videoUUID)
+
+  if (!videoAbuse) {
+    const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
+    const video = await fetchVideo(videoUUID, 'all', userId)
+
+    if (video) videoAbuse = await VideoAbuseModel.loadByIdAndVideoId(abuseId, video.id)
+  }
 
   if (videoAbuse === null) {
     res.status(404)
index bc6c58b0653fdcf2236328413a547e186c84412f..c8623a5d43cf31cfb4884b0823afe258059c995e 100644 (file)
@@ -14,7 +14,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 485
+const LAST_MIGRATION_VERSION = 490
 
 // ---------------------------------------------------------------------------
 
diff --git a/server/initializers/migrations/0490-abuse-video.ts b/server/initializers/migrations/0490-abuse-video.ts
new file mode 100644 (file)
index 0000000..26333fe
--- /dev/null
@@ -0,0 +1,28 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+}): Promise<void> {
+
+  const deletedVideo = {
+    type: Sequelize.JSONB,
+    allowNull: true
+  }
+  await utils.queryInterface.addColumn('videoAbuse', 'deletedVideo', deletedVideo)
+  await utils.sequelize.query(`ALTER TABLE "videoAbsue" ALTER COLUMN "videoId" DROP NOT NULL;`)
+  await utils.sequelize.query(`ALTER TABLE "videoAbuse" DROP CONSTRAINT IF EXISTS "videoAbuse_videoId_fkey";`)
+  await utils.sequelize.query(`ALTER TABLE "videoAbuse" ADD CONSTRAINT "videoAbuse_videoId_fkey" 
+  FOREIGN KEY ("videoId") REFERENCES video(id) ON UPDATE CASCADE ON DELETE SET NULL;`)
+
+}
+
+function down (options) {
+  throw new Error('Not implemented.')
+}
+
+export {
+  up,
+  down
+}
index a4aef4024a8d9e470cbe1c3f0163df9bd1e0d699..7c316fe1343a8b7b196e46c230bb405af499ba0d 100644 (file)
@@ -32,8 +32,7 @@ const videoAbuseGetValidator = [
     logger.debug('Checking videoAbuseGetValidator parameters', { parameters: req.body })
 
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res)) return
-    if (!await doesVideoAbuseExist(req.params.id, res.locals.videoAll.id, res)) return
+    if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
 
     return next()
   }
@@ -53,8 +52,7 @@ const videoAbuseUpdateValidator = [
     logger.debug('Checking videoAbuseUpdateValidator parameters', { parameters: req.body })
 
     if (areValidationErrors(req, res)) return
-    if (!await doesVideoExist(req.params.videoId, res)) return
-    if (!await doesVideoAbuseExist(req.params.id, res.locals.videoAll.id, res)) return
+    if (!await doesVideoAbuseExist(req.params.id, req.params.videoId, res)) return
 
     return next()
   }
index da8c1577cc8753c4e170955ea1391868472ba842..ea985621317a5b9d142df807c4a1c653193b06a4 100644 (file)
@@ -9,7 +9,7 @@ import {
 import { AccountModel } from '../account/account'
 import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
 import { VideoModel } from './video'
-import { VideoAbuseState } from '../../../shared'
+import { VideoAbuseState, Video } from '../../../shared'
 import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
 import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../typings/models'
 import * as Bluebird from 'bluebird'
@@ -46,6 +46,11 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max))
   moderationComment: string
 
+  @AllowNull(true)
+  @Default(null)
+  @Column(DataType.JSONB)
+  deletedVideo: Video
+
   @CreatedAt
   createdAt: Date
 
@@ -58,9 +63,9 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
 
   @BelongsTo(() => AccountModel, {
     foreignKey: {
-      allowNull: false
+      allowNull: true
     },
-    onDelete: 'cascade'
+    onDelete: 'set null'
   })
   Account: AccountModel
 
@@ -70,17 +75,21 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
 
   @BelongsTo(() => VideoModel, {
     foreignKey: {
-      allowNull: false
+      allowNull: true
     },
-    onDelete: 'cascade'
+    onDelete: 'set null'
   })
   Video: VideoModel
 
-  static loadByIdAndVideoId (id: number, videoId: number): Bluebird<MVideoAbuse> {
+  static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> {
+    const videoAttributes = {}
+    if (videoId) videoAttributes['videoId'] = videoId
+    if (uuid) videoAttributes['deletedVideo'] = { uuid }
+
     const query = {
       where: {
         id,
-        videoId
+        ...videoAttributes
       }
     }
     return VideoAbuseModel.findOne(query)
@@ -112,7 +121,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
         },
         {
           model: VideoModel,
-          required: true
+          required: false
         }
       ]
     }
@@ -124,6 +133,10 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
   }
 
   toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse {
+    const video = this.Video
+      ? this.Video
+      : this.deletedVideo
+
     return {
       id: this.id,
       reason: this.reason,
@@ -134,9 +147,11 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
       },
       moderationComment: this.moderationComment,
       video: {
-        id: this.Video.id,
-        uuid: this.Video.uuid,
-        name: this.Video.name
+        id: video.id,
+        uuid: video.uuid,
+        name: video.name,
+        nsfw: video.nsfw,
+        deleted: !this.Video
       },
       createdAt: this.createdAt
     }
index 0e7505af5099052f835662902a4152ad973e8dc9..2636ebd8ebd6a2aac7f2fb3312bb9df582a6b652 100644 (file)
@@ -628,9 +628,9 @@ export class VideoModel extends Model<VideoModel> {
   @HasMany(() => VideoAbuseModel, {
     foreignKey: {
       name: 'videoId',
-      allowNull: false
+      allowNull: true
     },
-    onDelete: 'cascade'
+    onDelete: 'set null'
   })
   VideoAbuses: VideoAbuseModel[]
 
@@ -798,6 +798,35 @@ export class VideoModel extends Model<VideoModel> {
     ModelCache.Instance.invalidateCache('video', instance.id)
   }
 
+  @BeforeDestroy
+  static async saveEssentialDataToAbuses (instance: VideoModel, options) {
+    const tasks: Promise<any>[] = []
+
+    logger.info('Saving video abuses details of video %s.', instance.url)
+
+    if (!Array.isArray(instance.VideoAbuses)) {
+      instance.VideoAbuses = await instance.$get('VideoAbuses')
+
+      if (instance.VideoAbuses.length === 0) return undefined
+    }
+
+    const details = instance.toFormattedJSON()
+
+    for (const abuse of instance.VideoAbuses) {
+      tasks.push((_ => {
+        abuse.deletedVideo = details
+        return abuse.save({ transaction: options.transaction })
+      })())
+    }
+
+    Promise.all(tasks)
+           .catch(err => {
+             logger.error('Some errors when saving details of video %s in its abuses before destroy hook.', instance.uuid, { err })
+           })
+
+    return undefined
+  }
+
   static listLocal (): Bluebird<MVideoWithAllFiles[]> {
     const query = {
       where: {
index 955ec478057e6f6583a26d123acc1040ac1f50d1..49bd1ff2e7e9e20d585935ce188ab14199d516d2 100644 (file)
@@ -31,4 +31,4 @@ export type MVideoAbuseAccountVideo =
 export type MVideoAbuseFormattable =
   MVideoAbuse &
   Use<'Account', MAccountFormattable> &
-  Use<'Video', Pick<MVideo, 'id' | 'uuid' | 'name'>>
+  Use<'Video', Pick<MVideo, 'id' | 'uuid' | 'name' | 'nsfw'>>
index 4f668795a86cc5a065eeaf42defade88621fdb4d..b47ee05a0a119abddd5367b61aeec92660a50104 100644 (file)
@@ -14,6 +14,8 @@ export interface VideoAbuse {
     id: number
     name: string
     uuid: string
+    nsfw: boolean
+    deleted: boolean
   }
 
   createdAt: Date