Procházet zdrojové kódy

Content reporting (#37)

Bartosz Dryl před 2 roky
rodič
revize
a91a57c6b4

+ 3 - 4
.eslintrc.js

@@ -1,6 +1,5 @@
 module.exports = {
-  extends: [ 'eslint:recommended',
-    'plugin:@typescript-eslint/recommended', 'plugin:jest/recommended', 'prettier'],
+  extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:jest/recommended', 'prettier'],
   env: {
     node: true,
     es6: true,
@@ -31,7 +30,7 @@ module.exports = {
       },
       {
         selector: 'enumMember',
-        format: ['PascalCase'],
+        format: null,
       },
       {
         selector: 'typeLike',
@@ -44,5 +43,5 @@ module.exports = {
         format: ['PascalCase'],
       },
     ],
-  }
+  },
 }

+ 29 - 0
schema.graphql

@@ -13,6 +13,11 @@ type ChannelFollowsInfo {
   id: ID!
 }
 
+"""
+The javascript `Date` as string. Type represents date and time as the ISO Date string.
+"""
+scalar DateTime
+
 type EntityViewsInfo {
   id: ID!
   views: Int!
@@ -34,6 +39,9 @@ type Mutation {
 
   """Add a single follow to the target channel"""
   followChannel(channelId: ID!): ChannelFollowsInfo!
+
+  """Report a video"""
+  reportVideo(rationale: String!, videoId: ID!): ReportVideoInfo!
   setCategoryFeaturedVideos(categoryId: ID!, videos: [FeaturedVideoInput!]!): [FeaturedVideo!]!
   setVideoHero(newVideoHero: VideoHeroInput!): VideoHero!
 
@@ -58,11 +66,20 @@ type Query {
 
   """Get list of most viewed categories of all time"""
   mostViewedCategoriesAllTime(limit: Int!): [EntityViewsInfo!]
+  reportedVideos(limit: Int = 30, orderBy: VideoReportOrderByInput = createdAt_DESC, skip: Int, where: VideoReportWhereInput): [ReportVideoInfo!]!
 
   """Get current video hero"""
   videoHero: VideoHero!
 }
 
+type ReportVideoInfo {
+  createdAt: DateTime!
+  id: ID!
+  rationale: String!
+  reporterIp: String!
+  videoId: ID!
+}
+
 type VideoHero {
   heroPosterUrl: String!
   heroTitle: String!
@@ -76,3 +93,15 @@ input VideoHeroInput {
   heroVideoCutUrl: String!
   videoId: ID!
 }
+
+enum VideoReportOrderByInput {
+  createdAt_ASC
+  createdAt_DESC
+}
+
+input VideoReportWhereInput {
+  createdAt_gt: DateTime
+  createdAt_lt: DateTime
+  reporterIp: String
+  videoId: ID
+}

+ 19 - 0
src/entities/ReportVideoInfo.ts

@@ -0,0 +1,19 @@
+import { Field, ID, ObjectType } from 'type-graphql'
+
+@ObjectType()
+export class ReportVideoInfo {
+  @Field(() => ID)
+  id: string
+
+  @Field(() => ID)
+  videoId: string
+
+  @Field()
+  rationale: string
+
+  @Field()
+  createdAt: Date
+
+  @Field()
+  reporterIp: string
+}

+ 21 - 0
src/models/ReportedContent.ts

@@ -0,0 +1,21 @@
+import { getModelForClass, prop } from '@typegoose/typegoose'
+
+export class ReportedVideo {
+  @prop({ required: true })
+  reporterIp: string
+
+  @prop({ required: true, index: true })
+  videoId: string
+
+  @prop({ required: true })
+  timestamp: Date
+
+  @prop({ required: true })
+  rationale: string
+}
+
+export const ReportedVideoModel = getModelForClass(ReportedVideo, { schemaOptions: { collection: 'reportedVideos' } })
+
+export const saveReportedVideo = (reportedVideo: ReportedVideo) => {
+  return ReportedVideoModel.create(reportedVideo)
+}

+ 3 - 1
src/resolvers/index.ts

@@ -1,4 +1,6 @@
+import { FeaturedContentResolver } from './featuredContent'
 import { ChannelFollowsInfosResolver } from './followsInfo'
+import { ReportsInfosResolver } from './reportsInfo'
 import { VideoViewsInfosResolver } from './viewsInfo'
 
-export { ChannelFollowsInfosResolver, VideoViewsInfosResolver }
+export { ChannelFollowsInfosResolver, VideoViewsInfosResolver, FeaturedContentResolver, ReportsInfosResolver }

+ 140 - 0
src/resolvers/reportsInfo.ts

@@ -0,0 +1,140 @@
+import {
+  Args,
+  ArgsType,
+  Authorized,
+  Ctx,
+  Field,
+  ID,
+  InputType,
+  Int,
+  MiddlewareFn,
+  Mutation,
+  Query,
+  registerEnumType,
+  Resolver,
+  UseMiddleware,
+} from 'type-graphql'
+import { ReportVideoInfo } from '../entities/ReportVideoInfo'
+import { ReportedVideoModel, saveReportedVideo } from '../models/ReportedContent'
+import { OrionContext } from '../types'
+
+const ONE_HOUR = 60 * 60 * 1000
+const MAX_REPORTS_PER_HOUR = 50
+
+export const rateLimit: (limit: number) => MiddlewareFn<OrionContext> =
+  (limit: number) =>
+  async ({ context: { remoteHost } }, next) => {
+    const reportsCountPerVideo = await ReportedVideoModel.count({
+      reporterIp: remoteHost,
+      timestamp: {
+        $gte: new Date(Date.now() - ONE_HOUR),
+      },
+    })
+
+    if (reportsCountPerVideo > limit) {
+      throw new Error('You have exceeded the maximum number of requests per hour')
+    }
+    return next()
+  }
+
+@ArgsType()
+class ReportVideoArgs {
+  @Field(() => ID)
+  videoId: string
+
+  @Field()
+  rationale: string
+}
+
+enum VideoReportOrderByInput {
+  createdAt_ASC = 'createdAt_ASC',
+  createdAt_DESC = 'createdAt_DESC',
+}
+
+registerEnumType(VideoReportOrderByInput, { name: 'VideoReportOrderByInput' })
+
+@InputType()
+class VideoReportWhereInput {
+  @Field(() => ID, { nullable: true })
+  videoId?: string
+
+  @Field(() => Date, { nullable: true })
+  createdAt_lt?: Date
+
+  @Field(() => Date, { nullable: true })
+  createdAt_gt?: Date
+
+  @Field({ nullable: true })
+  reporterIp: string
+}
+
+@ArgsType()
+class VideoReportsArgs {
+  @Field(() => VideoReportWhereInput, { nullable: true })
+  where: VideoReportWhereInput
+
+  @Field(() => VideoReportOrderByInput, {
+    nullable: true,
+    defaultValue: VideoReportOrderByInput.createdAt_DESC,
+  })
+  orderBy: VideoReportOrderByInput
+
+  @Field(() => Int, { nullable: true, defaultValue: 30 })
+  limit: number
+
+  @Field(() => Int, { nullable: true })
+  skip: number
+}
+
+@Resolver()
+export class ReportsInfosResolver {
+  @Mutation(() => ReportVideoInfo, { description: 'Report a video' })
+  @UseMiddleware(rateLimit(MAX_REPORTS_PER_HOUR))
+  async reportVideo(
+    @Args() { videoId, rationale }: ReportVideoArgs,
+    @Ctx() ctx: OrionContext
+  ): Promise<ReportVideoInfo> {
+    const createdAt = new Date()
+    const reportedVideo = await saveReportedVideo({
+      rationale,
+      videoId,
+      reporterIp: ctx.remoteHost || '',
+      timestamp: createdAt,
+    })
+
+    return {
+      rationale,
+      videoId,
+      createdAt,
+      id: reportedVideo.id,
+      reporterIp: ctx.remoteHost || '',
+    }
+  }
+
+  @Query(() => [ReportVideoInfo])
+  @Authorized()
+  async reportedVideos(
+    @Args() { orderBy, where, limit, skip }: VideoReportsArgs,
+    @Ctx() ctx: OrionContext
+  ): Promise<ReportVideoInfo[]> {
+    const reportedVideosDocument = await ReportedVideoModel.find({
+      ...(where?.videoId ? { videoId: where?.videoId } : {}),
+      ...(where?.createdAt_gt ? { timestamp: { $gte: where.createdAt_gt } } : {}),
+      ...(where?.createdAt_lt ? { timestamp: { $lte: where.createdAt_lt } } : {}),
+      ...(where?.reporterIp ? { reporterIp: where?.reporterIp } : {}),
+    })
+      .skip(skip)
+      .limit(limit)
+      .sort({ timestamp: orderBy.split('_').at(-1) })
+
+    return (
+      reportedVideosDocument.map((reportedVideo) => ({
+        rationale: reportedVideo.rationale,
+        videoId: reportedVideo.videoId,
+        createdAt: reportedVideo.timestamp,
+        id: reportedVideo.id,
+        reporterIp: ctx.remoteHost || '',
+      })) || []
+    )
+  }
+}

+ 7 - 3
src/server.ts

@@ -11,8 +11,12 @@ import 'reflect-metadata'
 import { buildSchema } from 'type-graphql'
 import { FollowsAggregate, ViewsAggregate } from './aggregates'
 import { customAuthChecker } from './helpers'
-import { ChannelFollowsInfosResolver, VideoViewsInfosResolver } from './resolvers'
-import { FeaturedContentResolver } from './resolvers/featuredContent'
+import {
+  ChannelFollowsInfosResolver,
+  VideoViewsInfosResolver,
+  FeaturedContentResolver,
+  ReportsInfosResolver,
+} from './resolvers'
 import { queryNodeStitchingResolvers } from './resolvers/queryNodeStitchingResolvers'
 import { Aggregates, OrionContext } from './types'
 import config from './config'
@@ -26,7 +30,7 @@ export const createServer = async (mongoose: Mongoose, aggregates: Aggregates, q
   })
 
   const orionSchema = await buildSchema({
-    resolvers: [VideoViewsInfosResolver, ChannelFollowsInfosResolver, FeaturedContentResolver],
+    resolvers: [VideoViewsInfosResolver, ChannelFollowsInfosResolver, ReportsInfosResolver, FeaturedContentResolver],
     authChecker: customAuthChecker,
     emitSchemaFile: 'schema.graphql',
     validate: true,