diff --git a/.env.example b/.env.example index cd7d2c34..205956d8 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,30 @@ REDIS_URL= #DEEP_API DEEPL_API_KEY=dumplicate_deepl_api_key +# --- Media moderation --- +# Every upload endpoint - post media, comment media, article covers, avatars +# and banners - is scanned for sexual, violent and gore content. Images are +# checked inside the upload request and never reach storage if refused; videos +# are stored, withheld from the read path, and checked by a background worker. +# Leave MODERATION_ENABLED=false locally and in tests: a stand-in then approves +# everything so no API key is needed to upload a picture. +MODERATION_ENABLED=true +SIGHTENGINE_API_USER= +SIGHTENGINE_API_SECRET= +# At or above the reject threshold an explicit class refuses the upload; at or +# above the sensitive one the media is served but flagged for the client to +# blur. Raw scores are stored on every asset so these can be retuned. +MODERATION_REJECT_THRESHOLD=0.75 +MODERATION_SENSITIVE_THRESHOLD=0.4 +MODERATION_REQUEST_TIMEOUT_MS=15000 +# The video worker. Runs every minute: a user is waiting to see their own post. +MEDIA_MODERATION_CRON="* * * * *" +MEDIA_MODERATION_BATCH_SIZE=10 +MEDIA_MODERATION_MAX_ATTEMPTS=3 +# How long a worker's claim is honoured. A process killed mid-scan leaves the +# asset claimed; without this the post carrying it would hide its media forever. +MEDIA_MODERATION_LEASE_SECONDS=600 + # --- Feed ranking --- # Weights the feed ranker mixes. A post in the viewer's language starts at 4x # the score of one they cannot read; roughly two half-lives of extra freshness @@ -64,7 +88,7 @@ FEED_HALF_LIFE_HOURS=18 # Most posts one author may hold in the ranked head. FEED_MAX_POSTS_PER_AUTHOR=3 # Largest share of the feed that may be in a language the viewer does not read. -FEED_FOREIGN_LANGUAGE_QUOTA=0.25 +FEED_FOREIGN_LANGUAGE_QUOTA=0.2 # Size of the candidate pool the ranker scores, and how far back it reaches. FEED_CANDIDATE_POOL_SIZE=300 FEED_CANDIDATE_WINDOW_DAYS=7 diff --git a/CLAUDE.md b/CLAUDE.md index 251cbd24..b8abc293 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,19 @@ Auth decorators: `fastify.authenticate` (required) and `fastify.optionalAuthenti `RateLimitPolicies` in `src/http/plugins/rate-limit.plugin.ts`: `STRICT` (3/15 min, `continueExceeding`) for login/register, `SENSITIVE` (5/min) for password reset, verification, and write/social actions, `STANDARD` (60/min) for authenticated reads, `PUBLIC` (100/min). Global default is 100/min. Requests with `Authorization: Bot ` are allow-listed after a sha256 lookup against `user.botToken`. +### Media moderation + +Every upload endpoint — `POST /media` (post *and* comment media), `POST /articles/cover`, `PATCH /me/avatar`, `PATCH /me/banner` — goes through `UploadModeratedMediaUseCase` (`src/core/use-cases/media/upload-moderated-media/`). It sniffs the format from the file's magic bytes (`src/core/use-cases/shared/media/detect-media-type.ts`; the client's MIME type and filename are never trusted), then splits: + +- **Images** are scanned before a byte reaches R2, so a refused file never gets a URL. A provider error fails the upload closed (`ModerationUnavailableError`, 503). +- **Videos** are stored as `PENDING` and scanned by the `media-moderation` cron worker, because the provider has to fetch and sample them. + +Each stored file gets a `MediaAsset` row. **That row is what makes an uploaded key trustworthy:** `CreatePostUseCase` and `CreateCommentUseCase` resolve every submitted `mediaUrls` entry back to an asset via `resolveAttachableMedia` and reject it (`MediaNotOwnedError`, 400) unless this author uploaded it, through the matching `MediaChannel`, and moderation did not reject it. Without that check the pipeline is decorative — the request body accepts arbitrary URLs. + +Verdicts are tiered: explicit sexual content, gore, self-harm and hate imagery are rejected; suggestive content, weapons and depicted violence only set `isSensitive` so the client blurs them (this platform is full of game screenshots). Thresholds and the class-to-tier map live in `src/infrastructure/external/moderation/score-to-verdict.ts`, and raw provider scores are stored on every asset so they can be retuned. + +Posts, comments and articles carry denormalised `isSensitive` / `mediaStatus` columns; the mappers withhold `mediaUrls` while `mediaStatus !== APPROVED` but still serve the text. `MODERATION_ENABLED=false` (test and local) swaps in `NoopModerationService`, which approves everything — it is never a fallback for a provider that is down. + ### Realtime and background jobs `FastifyRealtimeService` publishes to the Redis `realtime_events` channel; each instance subscribes and fans out to locally connected sockets via `WebSocketManager` — so notifications work across multiple processes. Never write to sockets directly from a use-case; go through `RealtimePort`. diff --git a/prisma/migrations/20260901000000_add_media_moderation/migration.sql b/prisma/migrations/20260901000000_add_media_moderation/migration.sql new file mode 100644 index 00000000..768c2910 --- /dev/null +++ b/prisma/migrations/20260901000000_add_media_moderation/migration.sql @@ -0,0 +1,105 @@ +-- Automated moderation for every uploaded image and video. +-- +-- Two things happen here. `media_assets` records one row per stored file: +-- who uploaded it, through which endpoint, and what the moderation provider +-- said about it. Posts, comments and articles gain the two denormalised +-- columns the read path needs so it can withhold media without joining. +-- +-- The asset table is what makes an uploaded key trustworthy. Scanning at +-- upload time only governs what the upload endpoint writes to storage; +-- nothing stops a client from skipping that endpoint and putting its own URL +-- straight into a post body. Content creation now resolves every submitted +-- URL back to a row here and refuses it unless this uploader owns it and +-- moderation did not reject it. +-- +-- Adding columns with a constant default is metadata-only in Postgres 11+, so +-- no table is rewritten. Existing rows become `APPROVED` and not sensitive, +-- which is the correct reading of them: they predate the pipeline, carry no +-- assets, and must not disappear from feeds because of it. Backfilling them +-- for a retroactive scan is a separate, out-of-band job. + +-- CreateEnum +CREATE TYPE "public"."MediaModerationStatus" AS ENUM ('PENDING', 'SCANNING', 'APPROVED', 'SENSITIVE', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "public"."MediaKind" AS ENUM ('IMAGE', 'VIDEO'); + +-- The upload endpoint a file came through, fixed when the bytes arrive. Posts +-- and comments share one endpoint, which is why POST_MEDIA covers both and why +-- "which of the two claimed it" is a separate column. +-- CreateEnum +CREATE TYPE "public"."MediaChannel" AS ENUM ('POST_MEDIA', 'ARTICLE_COVER', 'AVATAR', 'BANNER'); + +-- CreateEnum +CREATE TYPE "public"."MediaOwnerKind" AS ENUM ('POST', 'COMMENT', 'ARTICLE'); + +-- CreateTable +CREATE TABLE "public"."media_assets" ( + "id" TEXT NOT NULL, + "storage_key" TEXT NOT NULL, + "kind" "public"."MediaKind" NOT NULL, + "mime_type" TEXT NOT NULL, + "byte_size" INTEGER NOT NULL, + "uploader_id" TEXT NOT NULL, + "channel" "public"."MediaChannel" NOT NULL, + "owner_id" TEXT, + "owner_kind" "public"."MediaOwnerKind", + "status" "public"."MediaModerationStatus" NOT NULL DEFAULT 'PENDING', + "categories" TEXT[] DEFAULT ARRAY[]::TEXT[], + "scores" JSONB, + "provider" TEXT, + "moderated_at" TIMESTAMP(3), + "attempts" INTEGER NOT NULL DEFAULT 0, + "last_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id") +); + +-- One row per stored object, so a key can never resolve to two different +-- uploaders or two different verdicts. +-- CreateIndex +CREATE UNIQUE INDEX "media_assets_storage_key_key" ON "public"."media_assets" ("storage_key"); + +-- The worker's claim query is "oldest pending first", and it runs every +-- minute against a table that is mostly settled rows. +-- CreateIndex +CREATE INDEX "media_assets_status_created_at_idx" ON "public"."media_assets" ("status", "created_at"); + +-- CreateIndex +CREATE INDEX "media_assets_uploader_id_idx" ON "public"."media_assets" ("uploader_id"); + +-- Backs the worker rebuilding an owner's media list from its surviving assets. +-- CreateIndex +CREATE INDEX "media_assets_owner_kind_owner_id_idx" ON "public"."media_assets" ("owner_kind", "owner_id"); + +-- Deleting a user takes their assets with them; the objects themselves are +-- swept by the existing purge job. +-- AddForeignKey +ALTER TABLE "public"."media_assets" ADD CONSTRAINT "media_assets_uploader_id_fkey" FOREIGN KEY ("uploader_id") REFERENCES "public"."users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AlterTable +ALTER TABLE "public"."posts" + ADD COLUMN "is_sensitive" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "media_status" "public"."MediaModerationStatus" NOT NULL DEFAULT 'APPROVED'; + +-- AlterTable +ALTER TABLE "public"."comments" + ADD COLUMN "is_sensitive" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "media_status" "public"."MediaModerationStatus" NOT NULL DEFAULT 'APPROVED'; + +-- AlterTable +ALTER TABLE "public"."articles" + ADD COLUMN "is_sensitive" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "media_status" "public"."MediaModerationStatus" NOT NULL DEFAULT 'APPROVED'; + +-- Tells an author their upload was removed. Self-issued - it comes from the +-- platform, and there is no system account to attribute it to - so the type is +-- what carries the meaning, not the issuer. +-- +-- Postgres allows ALTER TYPE ... ADD VALUE inside a transaction from version +-- 12 on, provided the new value is not used in the same transaction. Nothing +-- here writes a MEDIA_REJECTED row. +-- AlterEnum +ALTER TYPE "public"."NotificationType" ADD VALUE 'MEDIA_REJECTED'; diff --git a/prisma/models/article.prisma b/prisma/models/article.prisma index ff71946c..d5c70c0e 100644 --- a/prisma/models/article.prisma +++ b/prisma/models/article.prisma @@ -16,6 +16,13 @@ model Article { coverImageKey String? @map("cover_image_key") coverImageAlt String? @map("cover_image_alt") @db.VarChar(160) + /// The cover is the only media an article carries, and it is always an + /// image, so it is scanned inside the upload request and never sits + /// PENDING. The fields exist so the read path can treat every content type + /// the same way. + isSensitive Boolean @default(false) @map("is_sensitive") + mediaStatus MediaModerationStatus @default(APPROVED) @map("media_status") + status ArticleStatus @default(DRAFT) publishedAt DateTime? @map("published_at") readingTimeMinutes Int @default(1) @map("reading_time_minutes") diff --git a/prisma/models/media.prisma b/prisma/models/media.prisma new file mode 100644 index 00000000..991c9132 --- /dev/null +++ b/prisma/models/media.prisma @@ -0,0 +1,84 @@ +/// Moderation lifecycle of a stored file. +/// +/// Images never sit in PENDING: they are scanned inside the upload request and +/// are only written to storage once a verdict exists. Videos do, because the +/// provider needs to fetch and sample them, which is far too slow to hold an +/// HTTP request open for. +enum MediaModerationStatus { + PENDING + SCANNING + APPROVED + SENSITIVE + REJECTED +} + +enum MediaKind { + IMAGE + VIDEO +} + +/// The upload endpoint a file came through. Chosen at upload time, so an avatar +/// can never be attached to a post afterwards. Posts and comments share one +/// endpoint, so the channel deliberately does not say which of the two claimed +/// the file - `ownerId` does that, once something has. +enum MediaChannel { + POST_MEDIA + ARTICLE_COVER + AVATAR + BANNER +} + +/// Which table `ownerId` points into. Posts and comments share one upload +/// endpoint, so the channel cannot say, and a verdict has to know where to be +/// written back to. +enum MediaOwnerKind { + POST + COMMENT + ARTICLE +} + +/// One stored file and everything the moderation pipeline knows about it. +/// +/// The row is what makes an uploaded key trustworthy: content creation looks a +/// key up here and refuses it unless this uploader owns it and it passed +/// moderation. Without that check the pipeline would be decorative, since a +/// client can put any URL it likes in a post body. +model MediaAsset { + id String @id @default(uuid()) + + /// The R2 object key, without any CDN prefix. + storageKey String @unique @map("storage_key") + kind MediaKind + /// Derived from the file's magic bytes, never from the client's claim. + mimeType String @map("mime_type") + byteSize Int @map("byte_size") + + uploaderId String @map("uploader_id") + uploader User @relation(fields: [uploaderId], references: [id], onDelete: Cascade) + + channel MediaChannel @map("channel") + /// The post or comment the asset ended up on. Null until the content that + /// uses it is created, which is what lets a purge job find abandoned uploads. + ownerId String? @map("owner_id") + ownerKind MediaOwnerKind? @map("owner_kind") + + status MediaModerationStatus @default(PENDING) + /// Flagged category labels, kept for auditing a rejection after the fact. + categories String[] @default([]) + /// Raw provider scores. Stored so thresholds can be retuned against real + /// traffic instead of guesses. + scores Json? + provider String? + + moderatedAt DateTime? @map("moderated_at") + attempts Int @default(0) + lastError String? @map("last_error") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([status, createdAt]) + @@index([uploaderId]) + @@index([ownerKind, ownerId]) + @@map("media_assets") +} diff --git a/prisma/models/notification.prisma b/prisma/models/notification.prisma index 7992ad37..f67b4ae3 100644 --- a/prisma/models/notification.prisma +++ b/prisma/models/notification.prisma @@ -6,6 +6,7 @@ enum NotificationType { COMMENT_LIKE COMMENT_REPLY QUOTE + MEDIA_REJECTED } model Notification { diff --git a/prisma/models/post.prisma b/prisma/models/post.prisma index 880d5ab4..e8cff014 100644 --- a/prisma/models/post.prisma +++ b/prisma/models/post.prisma @@ -26,6 +26,14 @@ model Post { /// rather than being pushed out of every feed. lang String? @db.VarChar(5) + /// True when moderation judged the media borderline rather than forbidden. + /// The post is served as normal; the client blurs the media behind a tap. + isSensitive Boolean @default(false) @map("is_sensitive") + /// APPROVED for a text-only post. PENDING while an attached video is still + /// being scanned and REJECTED once one failed, both of which make the read + /// path withhold the media while leaving the text visible. + mediaStatus MediaModerationStatus @default(APPROVED) @map("media_status") + authorId String @map("author_id") author User @relation(fields: [authorId], references: [id], onDelete: Cascade) @@ -41,9 +49,9 @@ model Post { bookmarks PostBookmark[] comments Comment[] notifications Notification[] - commentCount Int @default(0) - likeCount Int @default(0) - quoteCount Int @default(0) + commentCount Int @default(0) + likeCount Int @default(0) + quoteCount Int @default(0) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -115,6 +123,11 @@ model Comment { content String @db.Text mediaUrls String[] @default([]) @map("media_urls") + /// Mirrors the same two fields on Post: a comment carries media from the + /// same upload endpoint, so it needs the same withholding rules. + isSensitive Boolean @default(false) @map("is_sensitive") + mediaStatus MediaModerationStatus @default(APPROVED) @map("media_status") + // Exactly one of postId / articleId is set, enforced by a CHECK constraint // added in the migration: Prisma cannot express one. postId String? @map("post_id") @@ -132,8 +145,8 @@ model Comment { likes CommentLike[] bookmarks CommentBookmark[] notifications Notification[] - likeCount Int @default(0) @map("like_count") - replyCount Int @default(0) @map("reply_count") + likeCount Int @default(0) @map("like_count") + replyCount Int @default(0) @map("reply_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 65062e9c..20872a8c 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -29,6 +29,7 @@ model User { comments Comment[] commentBookmarks CommentBookmark[] + mediaAssets MediaAsset[] articles Article[] articleLikes ArticleLike[] articleBookmarks ArticleBookmark[] diff --git a/render.yaml b/render.yaml index 330a80da..b7002b43 100644 --- a/render.yaml +++ b/render.yaml @@ -11,6 +11,12 @@ projects: repo: https://github.com/the-developer-network/tdn-api plan: free envVars: + - key: MODERATION_ENABLED + sync: false + - key: SIGHTENGINE_API_USER + sync: false + - key: SIGHTENGINE_API_SECRET + sync: false - key: OTP_EXPIRY_SECONDS sync: false - key: USER_PURGE_GRACE_PERIOD_DAYS diff --git a/src/app.ts b/src/app.ts index fdea309d..c8814811 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import realtimeRoutes from "@routes/realtime.routes"; import notificationRoutes from "@routes/notification.routes"; import notificationPurgePlugin from "@plugins/custom/notification-purge.plugin"; import userInterestRebuildPlugin from "@plugins/custom/user-interest-rebuild.plugin"; +import mediaModerationPlugin from "@plugins/custom/media-moderation.plugin"; import { postRoutes } from "@routes/post/post.routes"; import { commentRoutes } from "@routes/post/comment.routes"; import { likeRoutes } from "@routes/post/like.routes"; @@ -105,6 +106,7 @@ export class App { this.server.register(userPurgePlugin); this.server.register(notificationPurgePlugin); this.server.register(userInterestRebuildPlugin); + this.server.register(mediaModerationPlugin); } /** diff --git a/src/core/domain/entities/article.entity.ts b/src/core/domain/entities/article.entity.ts index 5b98c3aa..d0a9f0dd 100644 --- a/src/core/domain/entities/article.entity.ts +++ b/src/core/domain/entities/article.entity.ts @@ -65,6 +65,9 @@ export interface CreateArticleData { /** Accessibility text for the cover image */ coverImageAlt?: string | null; + /** Whether moderation judged the cover borderline */ + isSensitive?: boolean; + /** Explicit tag names, already normalized by the caller */ tags?: string[]; @@ -102,6 +105,7 @@ export class Article { excerpt: Article.resolveExcerpt(body, data.excerpt), coverImageKey: data.coverImageKey ?? null, coverImageAlt: data.coverImageAlt ?? null, + isSensitive: data.isSensitive ?? false, status: ArticleStatus.DRAFT, publishedAt: null, readingTimeMinutes: Article.calculateReadingTime(body), @@ -306,6 +310,14 @@ export class Article { return this.props.coverImageAlt; } + /** + * Whether moderation judged the cover borderline. + * @returns True when the client should blur the cover behind a tap + */ + get isSensitive(): boolean { + return this.props.isSensitive ?? false; + } + /** The current lifecycle state */ get status(): ArticleStatus { return this.props.status; @@ -453,6 +465,7 @@ export class Article { excerpt?: string | null; coverImageKey?: string | null; coverImageAlt?: string | null; + isSensitive?: boolean; tags?: string[]; categories?: PostCategory[]; }): void { @@ -483,6 +496,11 @@ export class Article { if (changes.coverImageAlt !== undefined) { this.props.coverImageAlt = changes.coverImageAlt; } + // Follows the cover: swapping in a clean image has to clear the flag + // the previous one set, or the article stays blurred forever. + if (changes.isSensitive !== undefined) { + this.props.isSensitive = changes.isSensitive; + } if (changes.tags !== undefined) this.props.tags = changes.tags; if (changes.categories !== undefined) { this.props.categories = changes.categories; diff --git a/src/core/domain/entities/comment.entity.ts b/src/core/domain/entities/comment.entity.ts index 10f1ef1e..76c9fd86 100644 --- a/src/core/domain/entities/comment.entity.ts +++ b/src/core/domain/entities/comment.entity.ts @@ -2,6 +2,7 @@ * Comment entity representing a user comment on a post or an article * Supports nested comments through optional parent-child relationships */ +import { MediaModerationStatus } from "@core/domain/enums"; import type { CommentProps } from "@core/domain/interfaces/comment-props.interface"; import type { CommentTarget } from "@core/ports/repositories/comment.repository"; @@ -36,6 +37,35 @@ export class Comment { return this.props.mediaUrls || []; } + /** + * Whether moderation judged the attached media borderline + * @returns True when the client should blur the media behind a tap + */ + public get isSensitive(): boolean { + return this.props.isSensitive ?? false; + } + + /** + * Moderation state of the comment's own media + * @returns The stored status, defaulting to APPROVED for a text-only comment + */ + public get mediaStatus(): MediaModerationStatus { + return this.props.mediaStatus ?? MediaModerationStatus.APPROVED; + } + + /** + * Whether the read path may serve this comment's media URLs. + * + * A comment whose video has not been cleared is still served - the text + * was never in question - but its media is held back until a verdict + * exists. + * + * @returns True once the attached media has been cleared + */ + public get isMediaServable(): boolean { + return this.mediaStatus === MediaModerationStatus.APPROVED; + } + /** * Gets the ID of the post this comment belongs to * @returns The post ID, or null when the comment belongs to an article @@ -142,6 +172,8 @@ export class Comment { authorId: string, parentId: string | null = null, mediaUrls: string[] = [], + isSensitive = false, + mediaStatus: MediaModerationStatus = MediaModerationStatus.APPROVED, ): Comment { return new Comment({ content, @@ -150,6 +182,8 @@ export class Comment { authorId, parentId, mediaUrls, + isSensitive, + mediaStatus, }); } @@ -168,6 +202,8 @@ export class Comment { authorId: string, parentId: string | null = null, mediaUrls: string[] = [], + isSensitive = false, + mediaStatus: MediaModerationStatus = MediaModerationStatus.APPROVED, ): Comment { return new Comment({ content, @@ -176,6 +212,8 @@ export class Comment { authorId, parentId, mediaUrls, + isSensitive, + mediaStatus, }); } diff --git a/src/core/domain/entities/media-asset.entity.ts b/src/core/domain/entities/media-asset.entity.ts new file mode 100644 index 00000000..ff065d43 --- /dev/null +++ b/src/core/domain/entities/media-asset.entity.ts @@ -0,0 +1,171 @@ +import { + MediaKind, + MediaModerationStatus, + type MediaModerationCategory, + type MediaModerationVerdict, + type MediaChannel, + type MediaOwnerKind, +} from "@core/domain/enums"; +import type { MediaAssetProps } from "@core/domain/interfaces/media-asset-props.interface"; + +/** + * Rich domain model for a stored media file. + * + * The entity is what makes an uploaded storage key trustworthy. Content + * creation looks a key up and refuses it unless the asset belongs to the + * uploader and survived moderation, which is the only thing standing between + * the pipeline and a client that simply puts its own URL in a post body. + */ +export class MediaAsset { + private constructor(private readonly props: MediaAssetProps) {} + + /** + * Creates a new asset record for a file that is about to be, or has just + * been, written to storage. + * + * An image arrives with a verdict already in hand: it is scanned before a + * byte reaches storage, so storing it as PENDING would describe a state it + * was never in. A video has no verdict yet and starts PENDING for the + * worker to pick up. + * + * @param params - The stored file and, for an image, its verdict + * @returns A new MediaAsset instance + */ + public static create(params: { + storageKey: string; + kind: MediaKind; + mimeType: string; + byteSize: number; + uploaderId: string; + channel: MediaChannel; + verdict?: MediaModerationVerdict; + categories?: MediaModerationCategory[]; + scores?: Record | null; + provider?: string | null; + }): MediaAsset { + return new MediaAsset({ + storageKey: params.storageKey, + kind: params.kind, + mimeType: params.mimeType, + byteSize: params.byteSize, + uploaderId: params.uploaderId, + channel: params.channel, + ownerId: null, + ownerKind: null, + status: params.verdict ?? MediaModerationStatus.PENDING, + categories: params.categories ?? [], + scores: params.scores ?? null, + provider: params.provider ?? null, + moderatedAt: params.verdict ? new Date() : null, + attempts: 0, + lastError: null, + }); + } + + public static with(props: MediaAssetProps): MediaAsset { + return new MediaAsset(props); + } + + get id(): string { + return this.props.id!; + } + + get storageKey(): string { + return this.props.storageKey; + } + + get kind(): MediaKind { + return this.props.kind; + } + + get mimeType(): string { + return this.props.mimeType; + } + + get byteSize(): number { + return this.props.byteSize; + } + + get uploaderId(): string { + return this.props.uploaderId; + } + + get channel(): MediaChannel { + return this.props.channel; + } + + get ownerId(): string | null { + return this.props.ownerId ?? null; + } + + get ownerKind(): MediaOwnerKind | null { + return this.props.ownerKind ?? null; + } + + get status(): MediaModerationStatus { + return this.props.status; + } + + get categories(): MediaModerationCategory[] { + return this.props.categories; + } + + get scores(): Record | null { + return this.props.scores ?? null; + } + + get provider(): string | null { + return this.props.provider ?? null; + } + + get moderatedAt(): Date | null { + return this.props.moderatedAt ?? null; + } + + get attempts(): number { + return this.props.attempts; + } + + get createdAt(): Date { + return this.props.createdAt!; + } + + /** + * Whether the asset is a video, and therefore the worker's problem rather + * than the upload request's. + */ + get isVideo(): boolean { + return this.props.kind === MediaKind.VIDEO; + } + + /** + * Whether the read path may serve this asset's URL. + * + * SENSITIVE counts as servable: the content it hangs off is marked + * sensitive so the client blurs it, which is the point of having a middle + * verdict at all. + */ + get isServable(): boolean { + return ( + this.props.status === MediaModerationStatus.APPROVED || + this.props.status === MediaModerationStatus.SENSITIVE + ); + } + + /** + * Whether this asset may be attached to new content by the given user. + * + * Ownership is checked here rather than at the call site because it is the + * rule that makes the whole pipeline non-optional: an asset someone else + * uploaded, or one that has already failed, can never travel into a post. + * + * @param userId - The id of the user creating the content + * @returns True when the asset is theirs and has not been rejected + */ + public canBeAttachedBy(userId: string): boolean { + return ( + this.props.uploaderId === userId && + this.props.status !== MediaModerationStatus.REJECTED + ); + } +} diff --git a/src/core/domain/entities/post.entity.ts b/src/core/domain/entities/post.entity.ts index 4e3c0162..63349cf2 100644 --- a/src/core/domain/entities/post.entity.ts +++ b/src/core/domain/entities/post.entity.ts @@ -1,3 +1,4 @@ +import { MediaModerationStatus } from "@core/domain/enums/media-moderation-status.enum"; import type { PostType } from "@core/domain/enums/post-type.enum"; import type { PostProps } from "@core/domain/interfaces/post-props.interface"; import type { PostCategory } from "../enums/post-category-enum"; @@ -27,6 +28,10 @@ export class Post { * @param quotedPostId - Optional. The id of the post this one quotes. * @param lang - Optional. The detected language of the content, or null * when the detector could not tell. + * @param isSensitive - Optional. Whether the attached media was judged + * borderline by moderation. + * @param mediaStatus - Optional. Moderation state of the attached media; + * PENDING when an attached video has not been scanned yet. * @returns A new Post instance with the specified properties. */ public static create( @@ -37,6 +42,8 @@ export class Post { categories: PostCategory[] = [], quotedPostId?: string, lang: string | null = null, + isSensitive = false, + mediaStatus: MediaModerationStatus = MediaModerationStatus.APPROVED, ): Post { return new Post({ content, @@ -47,6 +54,8 @@ export class Post { categories, quotedPostId, lang, + isSensitive, + mediaStatus, }); } @@ -86,6 +95,35 @@ export class Post { return this.props.mediaUrls; } + /** + * Whether moderation judged the attached media borderline. + * @returns True when the client should blur the media behind a tap + */ + get isSensitive(): boolean { + return this.props.isSensitive ?? false; + } + + /** + * Moderation state of the post's own media. + * @returns The stored status, defaulting to APPROVED for a text-only post + */ + get mediaStatus(): MediaModerationStatus { + return this.props.mediaStatus ?? MediaModerationStatus.APPROVED; + } + + /** + * Whether the read path may serve this post's media URLs. + * + * A post whose video has not been cleared is still served - withholding + * the text as well would punish the author for a scan that has not + * finished - but its media is held back until a verdict exists. + * + * @returns True once the attached media has been cleared + */ + get isMediaServable(): boolean { + return this.mediaStatus === MediaModerationStatus.APPROVED; + } + /** * Get the author information of the post * @returns Object containing author ID and optional display details diff --git a/src/core/domain/enums/index.ts b/src/core/domain/enums/index.ts index e4495cd7..6ba44b91 100644 --- a/src/core/domain/enums/index.ts +++ b/src/core/domain/enums/index.ts @@ -8,3 +8,9 @@ export { NotificationType } from "./notification-type.enum"; export { TokenType } from "./token-type.enum"; export { PostCategory } from "./post-category-enum"; export { ArticleStatus } from "./article-status.enum"; +export { MediaKind } from "./media-kind.enum"; +export { MediaChannel } from "./media-channel.enum"; +export { MediaOwnerKind } from "./media-owner-kind.enum"; +export { MediaModerationStatus } from "./media-moderation-status.enum"; +export type { MediaModerationVerdict } from "./media-moderation-status.enum"; +export { MediaModerationCategory } from "./media-moderation-category.enum"; diff --git a/src/core/domain/enums/media-channel.enum.ts b/src/core/domain/enums/media-channel.enum.ts new file mode 100644 index 00000000..0c7ccb12 --- /dev/null +++ b/src/core/domain/enums/media-channel.enum.ts @@ -0,0 +1,27 @@ +/** + * The upload endpoint a file came through. + * + * Fixed at upload time, and it is what stops a key uploaded as an avatar from + * later being attached to a post. It deliberately does not say what the file + * ended up on: posts and comments share one upload endpoint, so at the moment + * the bytes arrive there is nothing yet to say which of the two will claim + * them. + * + * Mirrors the `MediaChannel` enum in the Prisma schema exactly. + */ +export enum MediaChannel { + /** + * The shared `POST /media` endpoint, which feeds both post and comment + * media. Accepts images and video. + */ + POST_MEDIA = "POST_MEDIA", + + /** + * An article cover image. + */ + ARTICLE_COVER = "ARTICLE_COVER", + + AVATAR = "AVATAR", + + BANNER = "BANNER", +} diff --git a/src/core/domain/enums/media-kind.enum.ts b/src/core/domain/enums/media-kind.enum.ts new file mode 100644 index 00000000..1e96b54d --- /dev/null +++ b/src/core/domain/enums/media-kind.enum.ts @@ -0,0 +1,18 @@ +/** + * The two families of file the platform accepts. + * + * Mirrors the `MediaKind` enum in the Prisma schema exactly, so domain values + * can be cast onto Prisma values without a translation layer. + */ +export enum MediaKind { + /** + * A still raster image. Scanned inside the upload request. + */ + IMAGE = "IMAGE", + + /** + * A video file. Scanned by the background worker, because the provider has + * to fetch and sample it. + */ + VIDEO = "VIDEO", +} diff --git a/src/core/domain/enums/media-moderation-category.enum.ts b/src/core/domain/enums/media-moderation-category.enum.ts new file mode 100644 index 00000000..3d2777ee --- /dev/null +++ b/src/core/domain/enums/media-moderation-category.enum.ts @@ -0,0 +1,49 @@ +/** + * The kinds of forbidden or borderline content moderation looks for. + * + * Deliberately provider-neutral: a Sightengine class name or a Rekognition + * label is mapped onto one of these before it reaches the domain, so swapping + * providers does not change what the rest of the codebase reads. + */ +export enum MediaModerationCategory { + /** + * Exposed nudity, short of a depicted sexual act. + */ + NUDITY = "NUDITY", + + /** + * A depicted sexual act. + */ + SEXUAL_ACTIVITY = "SEXUAL_ACTIVITY", + + /** + * Suggestive but clothed. Only ever borderline, never a rejection on its + * own. + */ + SUGGESTIVE = "SUGGESTIVE", + + /** + * Blood, injury, corpses, mutilation. + */ + GORE = "GORE", + + /** + * Depicted physical violence against a person or animal. + */ + VIOLENCE = "VIOLENCE", + + /** + * A weapon shown in a threatening context. + */ + WEAPON = "WEAPON", + + /** + * Self-harm or its promotion. + */ + SELF_HARM = "SELF_HARM", + + /** + * Hate symbols and comparable offensive imagery. + */ + OFFENSIVE = "OFFENSIVE", +} diff --git a/src/core/domain/enums/media-moderation-status.enum.ts b/src/core/domain/enums/media-moderation-status.enum.ts new file mode 100644 index 00000000..d7293dd0 --- /dev/null +++ b/src/core/domain/enums/media-moderation-status.enum.ts @@ -0,0 +1,48 @@ +/** + * Moderation lifecycle of a stored file. + * + * Mirrors the `MediaModerationStatus` enum in the Prisma schema exactly, so + * domain values can be cast onto Prisma values without a translation layer. + */ +export enum MediaModerationStatus { + /** + * Uploaded but not yet judged. Only videos reach this state: an image is + * scanned before it is written to storage, so it is never stored unjudged. + */ + PENDING = "PENDING", + + /** + * Claimed by a worker and currently being scanned. Exists purely so two + * instances cannot pick up the same asset. + */ + SCANNING = "SCANNING", + + /** + * Clean. Served normally. + */ + APPROVED = "APPROVED", + + /** + * Borderline rather than forbidden. Served, but the content that carries it + * is marked sensitive so the client can blur it behind a tap. + */ + SENSITIVE = "SENSITIVE", + + /** + * Forbidden. The object is deleted from storage and the read path never + * returns its URL again. + */ + REJECTED = "REJECTED", +} + +/** + * The three verdicts a moderation provider can return. + * + * A narrowed view of {@link MediaModerationStatus}: `PENDING` and `SCANNING` + * describe where an asset sits in the pipeline, not what the provider said, + * and a port implementation must never produce them. + */ +export type MediaModerationVerdict = + | MediaModerationStatus.APPROVED + | MediaModerationStatus.SENSITIVE + | MediaModerationStatus.REJECTED; diff --git a/src/core/domain/enums/media-owner-kind.enum.ts b/src/core/domain/enums/media-owner-kind.enum.ts new file mode 100644 index 00000000..30889e08 --- /dev/null +++ b/src/core/domain/enums/media-owner-kind.enum.ts @@ -0,0 +1,15 @@ +/** + * Which kind of content an asset ended up on. + * + * Posts and comments share one upload endpoint, so {@link MediaChannel} cannot + * tell them apart. This is set when the content that uses the asset is + * created, and it is what lets the background worker write a video's verdict + * back to the right table. + * + * Mirrors the `MediaOwnerKind` enum in the Prisma schema exactly. + */ +export enum MediaOwnerKind { + POST = "POST", + COMMENT = "COMMENT", + ARTICLE = "ARTICLE", +} diff --git a/src/core/domain/enums/notification-type.enum.ts b/src/core/domain/enums/notification-type.enum.ts index 904d187f..37a0e00a 100644 --- a/src/core/domain/enums/notification-type.enum.ts +++ b/src/core/domain/enums/notification-type.enum.ts @@ -35,4 +35,10 @@ export enum NotificationType { * Notification when a user quotes another user's post */ QUOTE = "QUOTE", + + /** + * The user's own media was removed by moderation. Self-issued: it comes + * from the platform rather than another user. + */ + MEDIA_REJECTED = "MEDIA_REJECTED", } diff --git a/src/core/domain/interfaces/article-props.interface.ts b/src/core/domain/interfaces/article-props.interface.ts index 13a6789e..9e7b1d0e 100644 --- a/src/core/domain/interfaces/article-props.interface.ts +++ b/src/core/domain/interfaces/article-props.interface.ts @@ -30,6 +30,16 @@ export interface ArticleProps { /** Accessibility text for the cover image */ coverImageAlt: string | null; + /** + * True when moderation judged the cover borderline rather than forbidden. + * The article is served as normal; the client blurs the cover. + * + * A cover is always an image and is therefore scanned inside the upload + * request, so there is no pending state to represent here - a forbidden + * cover never reached storage to be referenced in the first place. + */ + isSensitive?: boolean; + /** Lifecycle state controlling who may read the article */ status: ArticleStatus; diff --git a/src/core/domain/interfaces/comment-props.interface.ts b/src/core/domain/interfaces/comment-props.interface.ts index b67185bd..edaac218 100644 --- a/src/core/domain/interfaces/comment-props.interface.ts +++ b/src/core/domain/interfaces/comment-props.interface.ts @@ -1,3 +1,5 @@ +import type { MediaModerationStatus } from "@core/domain/enums"; + /** * Interface defining the properties of a comment entity * Supports nested comments through optional parent-child relationships @@ -90,4 +92,19 @@ export interface CommentProps { * Array of media URLs attached to the comment */ mediaUrls?: string[]; + + /** + * True when moderation judged the attached media borderline rather than + * forbidden. The comment is served as normal and the client blurs the media. + */ + isSensitive?: boolean; + + /** + * Moderation state of the comment's own media. + * + * APPROVED for a text-only comment. PENDING while an attached video is + * still being scanned and REJECTED once one failed - in both of those the + * read path withholds the media and serves the text. + */ + mediaStatus?: MediaModerationStatus; } diff --git a/src/core/domain/interfaces/media-asset-props.interface.ts b/src/core/domain/interfaces/media-asset-props.interface.ts new file mode 100644 index 00000000..0066301c --- /dev/null +++ b/src/core/domain/interfaces/media-asset-props.interface.ts @@ -0,0 +1,62 @@ +import type { + MediaKind, + MediaModerationCategory, + MediaModerationStatus, + MediaChannel, + MediaOwnerKind, +} from "@core/domain/enums"; + +/** + * The persisted shape of a media asset. + * + * Everything the moderation pipeline knows about one stored file: where it + * lives, who put it there, what it was uploaded for, and what the provider + * said about it. + */ +export interface MediaAssetProps { + /** Set once persisted. */ + id?: string; + + /** The R2 object key, without any CDN prefix. */ + storageKey: string; + + kind: MediaKind; + + /** Derived from the file's magic bytes, never from the client's claim. */ + mimeType: string; + + byteSize: number; + + uploaderId: string; + + channel: MediaChannel; + + /** + * The post or comment the asset ended up on. Undefined until the content + * that uses it is created. + */ + ownerId?: string | null; + + /** Which table {@link MediaAssetProps.ownerId} points into. */ + ownerKind?: MediaOwnerKind | null; + + status: MediaModerationStatus; + + /** Flagged categories, kept so a rejection can be audited afterwards. */ + categories: MediaModerationCategory[]; + + /** Raw provider scores, kept so thresholds can be retuned against real traffic. */ + scores?: Record | null; + + provider?: string | null; + + moderatedAt?: Date | null; + + /** How many times the worker has tried and failed to reach a verdict. */ + attempts: number; + + lastError?: string | null; + + createdAt?: Date; + updatedAt?: Date; +} diff --git a/src/core/domain/interfaces/post-props.interface.ts b/src/core/domain/interfaces/post-props.interface.ts index 1a8d0dc4..0a3e1146 100644 --- a/src/core/domain/interfaces/post-props.interface.ts +++ b/src/core/domain/interfaces/post-props.interface.ts @@ -1,4 +1,4 @@ -import type { PostType } from "@core/domain/enums"; +import type { MediaModerationStatus, PostType } from "@core/domain/enums"; import type { PostCategory } from "../enums/post-category-enum"; import type { QuotedPostSnapshot } from "./quoted-post.interface"; @@ -84,6 +84,21 @@ export interface PostProps { */ lang?: string | null; + /** + * True when moderation judged the attached media borderline rather than + * forbidden. The post is served as normal and the client blurs the media. + */ + isSensitive?: boolean; + + /** + * Moderation state of the post's own media. + * + * APPROVED for a text-only post. PENDING while an attached video is still + * being scanned and REJECTED once one failed - in both of those the read + * path withholds the media and serves the text. + */ + mediaStatus?: MediaModerationStatus; + /** * The post this one quotes, when it is a quote post. * diff --git a/src/core/domain/interfaces/quoted-post.interface.ts b/src/core/domain/interfaces/quoted-post.interface.ts index e300b393..58dd6448 100644 --- a/src/core/domain/interfaces/quoted-post.interface.ts +++ b/src/core/domain/interfaces/quoted-post.interface.ts @@ -1,3 +1,5 @@ +import type { MediaModerationStatus } from "@core/domain/enums"; + /** * The snapshot of a post as it appears embedded inside a quote post. * @@ -17,6 +19,16 @@ export interface QuotedPostSnapshot { /** Media attached to the quoted post */ mediaUrls: string[]; + /** Whether the quoted post's media was judged borderline by moderation */ + isSensitive?: boolean; + + /** + * Moderation state of the quoted post's media. A quote card must withhold + * unscanned media on the same terms as the post itself, or quoting would + * be a way to publish a video before it was cleared. + */ + mediaStatus?: MediaModerationStatus; + /** When the quoted post was created */ createdAt: Date; diff --git a/src/core/errors/index.ts b/src/core/errors/index.ts index ace833ff..59e8f32b 100644 --- a/src/core/errors/index.ts +++ b/src/core/errors/index.ts @@ -25,6 +25,11 @@ export * from "./post/invalid-media-type.error"; export * from "./post/media-limit-exceeded.error"; export * from "./post/no-media-provided.error"; +// Media moderation errors +export * from "./media/media-not-owned.error"; +export * from "./media/media-rejected.error"; +export * from "./media/moderation-unavailable.error"; + // Article errors export * from "./article/article-not-published.error"; export * from "./article/invalid-article-state.error"; diff --git a/src/core/errors/media/media-not-owned.error.ts b/src/core/errors/media/media-not-owned.error.ts new file mode 100644 index 00000000..c449bbd8 --- /dev/null +++ b/src/core/errors/media/media-not-owned.error.ts @@ -0,0 +1,24 @@ +import { CustomError } from "../common/custom.error"; + +/** + * Error thrown when content references a media key its author cannot use. + * + * Covers all three ways a key can fail to belong to the request: no upload + * ever produced it, someone else uploaded it, or moderation already rejected + * it. They share one error deliberately - telling a caller which of the three + * applies would let them probe for keys that exist. + * + * @extends CustomError + */ +export class MediaNotOwnedError extends CustomError { + /** + * Creates a new MediaNotOwnedError instance. + * + * @param message - Optional custom error message + */ + constructor( + message = "One or more media items are not available. Upload them again and retry.", + ) { + super(message, 400); + } +} diff --git a/src/core/errors/media/media-rejected.error.ts b/src/core/errors/media/media-rejected.error.ts new file mode 100644 index 00000000..d6576a7b --- /dev/null +++ b/src/core/errors/media/media-rejected.error.ts @@ -0,0 +1,23 @@ +import { CustomError } from "../common/custom.error"; + +/** + * Error thrown when moderation refuses an uploaded file. + * + * The upload is abandoned rather than stored and hidden: content this error + * covers - explicit sexual imagery, gore, depicted violence - has no state in + * which the platform wants to be holding it. + * + * @extends CustomError + */ +export class MediaRejectedError extends CustomError { + /** + * Creates a new MediaRejectedError instance. + * + * @param message - Optional custom error message + */ + constructor( + message = "This file was rejected because it appears to contain explicit or violent content.", + ) { + super(message, 422); + } +} diff --git a/src/core/errors/media/moderation-unavailable.error.ts b/src/core/errors/media/moderation-unavailable.error.ts new file mode 100644 index 00000000..7f6824b7 --- /dev/null +++ b/src/core/errors/media/moderation-unavailable.error.ts @@ -0,0 +1,23 @@ +import { CustomError } from "../common/custom.error"; + +/** + * Error thrown when the moderation provider could not be reached. + * + * The upload fails closed. Letting an unscanned file through whenever the + * provider is down would turn every outage into an open door, and an outage is + * exactly when someone testing the limits would try again. + * + * @extends CustomError + */ +export class ModerationUnavailableError extends CustomError { + /** + * Creates a new ModerationUnavailableError instance. + * + * @param message - Optional custom error message + */ + constructor( + message = "Media could not be checked right now. Please try again in a moment.", + ) { + super(message, 503); + } +} diff --git a/src/core/ports/repositories/comment.repository.ts b/src/core/ports/repositories/comment.repository.ts index f432595a..2ef8de92 100644 --- a/src/core/ports/repositories/comment.repository.ts +++ b/src/core/ports/repositories/comment.repository.ts @@ -1,3 +1,4 @@ +import type { MediaState } from "@core/ports/repositories/media-asset.repository"; /** * Repository interface for comment data operations * Handles CRUD operations for comments and nested comment relationships @@ -122,6 +123,20 @@ export interface ICommentRepository { */ incrementRepliesCount(commentId: string): Promise; + /** + * Overwrites the media state written by moderation. + * + * Used by the background worker once a video has a verdict. The full media + * list is passed rather than a diff: the surviving assets already describe + * exactly what the content should carry, and computing a removal against a + * row that may have changed underneath is how a race turns into a media + * list that is missing something. + * + * @param id - The id of the content to update + * @param state - The media list and moderation flags to store + */ + updateMediaState(id: string, state: MediaState): Promise; + /** * Decrements the cached reply count of a comment by one * @param commentId - The ID of the comment to update diff --git a/src/core/ports/repositories/media-asset.repository.ts b/src/core/ports/repositories/media-asset.repository.ts new file mode 100644 index 00000000..703f66ce --- /dev/null +++ b/src/core/ports/repositories/media-asset.repository.ts @@ -0,0 +1,140 @@ +import type { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import type { + MediaModerationCategory, + MediaModerationStatus, + MediaOwnerKind, +} from "@core/domain/enums"; + +/** + * The media-related columns moderation owns on a post or a comment. + */ +export interface MediaState { + /** The media URLs that survived moderation, in upload order. */ + mediaUrls: string[]; + + /** Whether the client should blur what is left. */ + isSensitive: boolean; + + /** Whether anything attached is still unscanned or was rejected. */ + mediaStatus: MediaModerationStatus; +} + +/** + * The outcome the worker writes back after scanning a video. + */ +export interface MediaModerationOutcome { + status: MediaModerationStatus; + categories: MediaModerationCategory[]; + scores?: Record | null; + provider?: string | null; +} + +/** + * Port interface for media asset persistence. + */ +export interface IMediaAssetRepository { + /** + * Persists a newly uploaded asset. + * + * @param asset - The asset to store + * @returns The stored asset, with its generated id + */ + create(asset: MediaAsset): Promise; + + /** + * Looks up assets by their storage keys. + * + * Keys not present in the table simply do not appear in the result, which + * is what lets the caller reject a key nobody uploaded. + * + * @param storageKeys - The keys to resolve + * @returns The assets that exist, in no particular order + */ + findByStorageKeys(storageKeys: string[]): Promise; + + /** + * Claims up to `limit` unscanned assets for this process. + * + * Must be atomic. The API runs as several instances - the realtime layer + * exists precisely because it does - and two of them picking up the same + * asset would spend two provider calls to reach one verdict. + * + * Assets left in SCANNING for longer than the lease are claimed again. + * A process killed between claiming an asset and recording its verdict - + * a redeploy, an OOM - would otherwise strand it in a state nothing + * selects, and the post carrying it would withhold its media forever. + * + * @param limit - Most assets to claim in one batch + * @param leaseSeconds - How long a claim is honoured before it is reclaimed + * @returns The claimed assets, already moved to SCANNING + */ + claimPending(limit: number, leaseSeconds: number): Promise; + + /** + * Records the verdict for a scanned asset. + * + * @param id - The asset's id + * @param outcome - The verdict and its supporting detail + */ + recordOutcome(id: string, outcome: MediaModerationOutcome): Promise; + + /** + * Releases an asset back to PENDING after a failed attempt. + * + * @param id - The asset's id + * @param error - What went wrong, for operators reading the table later + * @returns The attempt count after the increment + */ + recordFailedAttempt(id: string, error: string): Promise; + + /** + * Binds assets to the content that now uses them. + * + * The channel is not a parameter: it was fixed when the file was uploaded, + * and the caller has already refused any key whose channel was wrong. + * + * Only assets nothing has claimed yet are attached, and the count of rows + * actually written is returned so the caller can tell a race apart from a + * success. Two requests can pass the same ownership check concurrently; + * without the guard both would attach, the later one would win, and the + * earlier post would hold media whose verdict is written somewhere else. + * + * @param storageKeys - The keys being attached + * @param ownerKind - Whether a post or a comment is claiming them + * @param ownerId - The id of that post or comment + * @returns How many assets were attached + */ + attachToOwner( + storageKeys: string[], + ownerKind: MediaOwnerKind, + ownerId: string, + ): Promise; + + /** + * Releases every asset attached to one piece of content. + * + * Used when content replaces its media: the superseded assets stop being + * claimed, so a purge job reading "attached" as "in use" does not keep + * them in storage forever. + * + * @param ownerKind - Whether the owner is a post, comment or article + * @param ownerId - The owner's id + */ + detachFromOwner(ownerKind: MediaOwnerKind, ownerId: string): Promise; + + /** + * Lists every asset attached to one piece of content, oldest first. + * + * The worker uses it to rebuild the owner's media list after a verdict: + * the surviving assets in upload order are exactly what the content should + * carry, which avoids having to describe the edit as a diff. + * + * @param ownerKind - Whether the owner is a post or a comment + * @param ownerId - The owner's id + * @returns The attached assets, oldest first + */ + findByOwner( + ownerKind: MediaOwnerKind, + ownerId: string, + ): Promise; +} diff --git a/src/core/ports/repositories/post.repository.ts b/src/core/ports/repositories/post.repository.ts index cefae837..1556adef 100644 --- a/src/core/ports/repositories/post.repository.ts +++ b/src/core/ports/repositories/post.repository.ts @@ -1,3 +1,4 @@ +import type { MediaState } from "@core/ports/repositories/media-asset.repository"; import type { PostType } from "@core/domain/enums/post-type.enum"; import type { Post } from "@core/domain/entities/post.entity"; import type { PostCategory } from "@core/domain/enums/post-category-enum"; @@ -154,6 +155,20 @@ export interface IPostRepository { */ incrementQuoteCount(postId: string): Promise; + /** + * Overwrites the media state written by moderation. + * + * Used by the background worker once a video has a verdict. The full media + * list is passed rather than a diff: the surviving assets already describe + * exactly what the content should carry, and computing a removal against a + * row that may have changed underneath is how a race turns into a media + * list that is missing something. + * + * @param id - The id of the content to update + * @param state - The media list and moderation flags to store + */ + updateMediaState(id: string, state: MediaState): Promise; + /** * Decrements the quote count for a post. * @param postId - The ID of the post whose quote was deleted. diff --git a/src/core/ports/services/logger.port.ts b/src/core/ports/services/logger.port.ts index f906b2be..3eda81f4 100644 --- a/src/core/ports/services/logger.port.ts +++ b/src/core/ports/services/logger.port.ts @@ -10,4 +10,16 @@ export interface LoggerPort { * @param message - The error message. */ error(object: object, message: string): void; + + /** + * Logs a warning with an object context and message. + * + * Separate from {@link LoggerPort.error} because a moderation rejection is + * the system working, not failing: it belongs in the record without + * raising an alert. + * + * @param object - The object context for the warning. + * @param message - The warning message. + */ + warn(object: object, message: string): void; } diff --git a/src/core/ports/services/media-moderation.port.ts b/src/core/ports/services/media-moderation.port.ts new file mode 100644 index 00000000..30dfb428 --- /dev/null +++ b/src/core/ports/services/media-moderation.port.ts @@ -0,0 +1,70 @@ +import type { + MediaModerationCategory, + MediaModerationVerdict, +} from "@core/domain/enums"; + +/** + * What a moderation provider concluded about one file. + */ +export interface MediaModerationResult { + /** + * The verdict the rest of the system acts on. + */ + verdict: MediaModerationVerdict; + + /** + * Which categories triggered the verdict. Empty for a clean file. + */ + categories: MediaModerationCategory[]; + + /** + * The provider's raw per-class scores, kept verbatim. + * + * Thresholds are a guess until they meet real traffic, and only the raw + * numbers make it possible to retune them against what was actually + * uploaded rather than against what we imagined would be. + */ + scores: Record; + + /** + * Identifies which provider produced the verdict, so stored results stay + * interpretable after a provider swap. + */ + provider: string; +} + +/** + * Port interface for automated content moderation of uploaded media. + * + * Following Clean Architecture principles, this interface defines the contract + * for moderation without exposing the provider behind it. + */ +export interface MediaModerationPort { + /** + * Scans a still image. + * + * Called inside the upload request, before a byte reaches storage, so that + * forbidden content is never stored even briefly. + * + * @param buffer - The image bytes + * @param mimeType - The MIME type detected from those bytes + * @returns The provider's verdict + */ + moderateImage( + buffer: Buffer, + mimeType: string, + ): Promise; + + /** + * Scans a video already in storage, by the URL the provider can fetch it + * from. + * + * This blocks for as long as the provider needs to sample the video, which + * is far too long for an HTTP request, so it is only ever called from the + * background worker. + * + * @param publicUrl - Publicly reachable URL of the stored video + * @returns The provider's verdict + */ + moderateVideo(publicUrl: string): Promise; +} diff --git a/src/core/ports/services/transaction.port.ts b/src/core/ports/services/transaction.port.ts index ae33a342..386734a3 100644 --- a/src/core/ports/services/transaction.port.ts +++ b/src/core/ports/services/transaction.port.ts @@ -8,6 +8,7 @@ import type { IBookmarkRepository } from "../repositories/bookmark.repository"; import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import type { IArticleLikeRepository } from "@core/ports/repositories/article-like.repository"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; /** * Provides transactional access to repositories within a single atomic operation. @@ -40,6 +41,16 @@ export interface TransactionContext { /** Repository for article like operations within the transaction. */ readonly articleLikeRepository: IArticleLikeRepository; + + /** + * Repository for media assets within the transaction. + * + * Media is bound to its content inside the same transaction that creates + * the content: a rollback that left assets pointing at a post which was + * never written would make an abandoned upload look claimed, and the purge + * job would then leave it in storage forever. + */ + readonly mediaAssetRepository: IMediaAssetRepository; } /** diff --git a/src/core/use-cases/article/create-article/create-article.usecase.ts b/src/core/use-cases/article/create-article/create-article.usecase.ts index 7937c814..8cff928f 100644 --- a/src/core/use-cases/article/create-article/create-article.usecase.ts +++ b/src/core/use-cases/article/create-article/create-article.usecase.ts @@ -1,3 +1,6 @@ +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import { MediaOwnerKind } from "@core/domain/enums"; +import { resolveCoverSensitivity } from "@core/use-cases/shared/media/resolve-cover-sensitivity"; import { Article } from "@core/domain/entities/article.entity"; import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import type { CryptoPort } from "@core/ports/services/crypto.port"; @@ -24,10 +27,12 @@ export class CreateArticleUseCase { * * @param articleRepository - Repository for managing article data * @param cryptoService - Source of the random slug suffix + * @param mediaAssetRepository - Repository holding the cover's moderation verdict */ constructor( private readonly articleRepository: IArticleRepository, private readonly cryptoService: CryptoPort, + private readonly mediaAssetRepository: IMediaAssetRepository, ) {} /** @@ -39,21 +44,37 @@ export class CreateArticleUseCase { * @throws BadRequestError - When the title, body, tags or cover key are invalid */ async execute(input: CreateArticleUseCaseInput): Promise
{ + const coverImageKey = validateCoverImageKey( + input.coverImageKey, + input.authorId, + ); + const article = Article.create({ title: normalizeTitle(input.title), body: normalizeBody(input.body), authorId: input.authorId, slugSuffix: this.cryptoService.generateRandomHex(SLUG_SUFFIX_BYTES), excerpt: input.excerpt, - coverImageKey: validateCoverImageKey( - input.coverImageKey, - input.authorId, - ), + coverImageKey, coverImageAlt: input.coverImageAlt ?? null, + isSensitive: await resolveCoverSensitivity( + coverImageKey, + this.mediaAssetRepository, + ), tags: normalizeTags(input.tags), categories: input.categories ?? [], }); - return await this.articleRepository.create(article); + const created = await this.articleRepository.create(article); + + if (coverImageKey) { + await this.mediaAssetRepository.attachToOwner( + [coverImageKey], + MediaOwnerKind.ARTICLE, + created.id, + ); + } + + return created; } } diff --git a/src/core/use-cases/article/get-articles/get-articles.usecase.ts b/src/core/use-cases/article/get-articles/get-articles.usecase.ts index e0b991f5..6a45b0f6 100644 --- a/src/core/use-cases/article/get-articles/get-articles.usecase.ts +++ b/src/core/use-cases/article/get-articles/get-articles.usecase.ts @@ -31,6 +31,7 @@ interface CachedArticle { excerpt: string | null; coverImageKey: string | null; coverImageAlt: string | null; + isSensitive: boolean; status: string; publishedAt: string | null; readingTimeMinutes: number; @@ -228,6 +229,10 @@ export class GetArticlesUseCase { excerpt: article.excerpt, coverImageKey: article.coverImageKey, coverImageAlt: article.coverImageAlt, + // Carried through the cache like every other stored field. Dropping + // it would serve a cover moderation judged borderline unblurred on + // the one endpoint where most people meet it. + isSensitive: article.isSensitive, status: article.status, publishedAt: article.publishedAt ? article.publishedAt.toISOString() @@ -265,6 +270,9 @@ export class GetArticlesUseCase { excerpt: entry.excerpt, coverImageKey: entry.coverImageKey, coverImageAlt: entry.coverImageAlt, + // Older cache entries predate the field; a missing one reads as + // "not flagged", which matches how those articles were stored. + isSensitive: entry.isSensitive ?? false, status: entry.status as ArticleStatus, publishedAt: entry.publishedAt ? new Date(entry.publishedAt) : null, readingTimeMinutes: entry.readingTimeMinutes, diff --git a/src/core/use-cases/article/update-article/update-article.usecase.ts b/src/core/use-cases/article/update-article/update-article.usecase.ts index a0f6374c..474eb0b4 100644 --- a/src/core/use-cases/article/update-article/update-article.usecase.ts +++ b/src/core/use-cases/article/update-article/update-article.usecase.ts @@ -1,3 +1,6 @@ +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import { MediaOwnerKind } from "@core/domain/enums"; +import { resolveCoverSensitivity } from "@core/use-cases/shared/media/resolve-cover-sensitivity"; import type { Article } from "@core/domain/entities/article.entity"; import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import type { CachePort } from "@core/ports/services/cache.port"; @@ -25,10 +28,12 @@ export class UpdateArticleUseCase { * * @param articleRepository - Repository for managing article data * @param cacheService - Cache used by the public article list + * @param mediaAssetRepository - Repository holding the cover's moderation verdict */ constructor( private readonly articleRepository: IArticleRepository, private readonly cacheService: CachePort, + private readonly mediaAssetRepository: IMediaAssetRepository, ) {} /** @@ -48,6 +53,11 @@ export class UpdateArticleUseCase { input.userId, ); + const coverImageKey = + input.coverImageKey === undefined + ? undefined + : validateCoverImageKey(input.coverImageKey, input.userId); + article.applyEdit({ title: input.title === undefined @@ -58,10 +68,17 @@ export class UpdateArticleUseCase { ? undefined : normalizeBody(input.body), excerpt: input.excerpt, - coverImageKey: - input.coverImageKey === undefined + coverImageKey, + // Recomputed only when the cover itself changed, and always then: + // swapping a borderline cover for a clean one has to clear the + // flag, or the article stays blurred forever. + isSensitive: + coverImageKey === undefined ? undefined - : validateCoverImageKey(input.coverImageKey, input.userId), + : await resolveCoverSensitivity( + coverImageKey, + this.mediaAssetRepository, + ), coverImageAlt: input.coverImageAlt, tags: input.tags === undefined @@ -72,6 +89,24 @@ export class UpdateArticleUseCase { const updated = await this.articleRepository.update(article); + if (coverImageKey !== undefined) { + // The cover changed, so whatever was attached before is superseded. + // Releasing it first keeps "attached" meaning "in use", which is + // what a storage purge would have to rely on. + await this.mediaAssetRepository.detachFromOwner( + MediaOwnerKind.ARTICLE, + updated.id, + ); + + if (coverImageKey) { + await this.mediaAssetRepository.attachToOwner( + [coverImageKey], + MediaOwnerKind.ARTICLE, + updated.id, + ); + } + } + if (updated.isPublished()) { await this.cacheService.deleteByPattern(ARTICLE_LIST_CACHE_PATTERN); } diff --git a/src/core/use-cases/article/upload-article-cover/detect-image-type.ts b/src/core/use-cases/article/upload-article-cover/detect-image-type.ts index 9839ff64..1a4f1c1c 100644 --- a/src/core/use-cases/article/upload-article-cover/detect-image-type.ts +++ b/src/core/use-cases/article/upload-article-cover/detect-image-type.ts @@ -1,3 +1,6 @@ +import { MediaKind } from "@core/domain/enums"; +import { detectMediaType } from "@core/use-cases/shared/media/detect-media-type"; + /** * A raster image format this API accepts for article covers. */ @@ -9,68 +12,24 @@ export interface DetectedImageType { mimeType: string; } -/** - * Compares a run of bytes against an expected signature. - * - * @param buffer - The uploaded bytes - * @param offset - Where the signature should start - * @param signature - The expected byte values - * @returns True when every byte matches - */ -function matches(buffer: Buffer, offset: number, signature: number[]): boolean { - if (buffer.length < offset + signature.length) return false; - - for (let i = 0; i < signature.length; i++) { - if (buffer[offset + i] !== signature[i]) return false; - } - - return true; -} - /** * Identifies an image by its magic bytes. * - * The client-supplied MIME type and file name are deliberately not consulted. - * Both are attacker-controlled: a request can claim `image/png` while carrying - * an SVG, and a name like `cover.png.html` reads as an image to a naive - * extension check. Reading the bytes is the only statement about the file the - * uploader cannot forge. - * - * SVG has no signature to match and is therefore rejected for free, which is - * the intended outcome: it is a scriptable document format rather than a - * raster image, and serving one from the CDN would be a stored XSS. + * A narrowing of {@link detectMediaType} rather than a second signature table: + * an article cover is always a still, so a video that passes the shared check + * must still be refused here. Keeping one table means a format is taught to + * the platform once. * * @param buffer - The uploaded bytes * @returns The detected type, or null when the bytes are not a supported image */ export function detectImageType(buffer: Buffer): DetectedImageType | null { - // JPEG: FF D8 FF - if (matches(buffer, 0, [0xff, 0xd8, 0xff])) { - return { extension: "jpg", mimeType: "image/jpeg" }; - } - - // PNG: 89 50 4E 47 0D 0A 1A 0A - if (matches(buffer, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { - return { extension: "png", mimeType: "image/png" }; - } - - // GIF: "GIF8" - if (matches(buffer, 0, [0x47, 0x49, 0x46, 0x38])) { - return { extension: "gif", mimeType: "image/gif" }; - } - - // WEBP: "RIFF" then "WEBP" at byte 8 - if ( - matches(buffer, 0, [0x52, 0x49, 0x46, 0x46]) && - matches(buffer, 8, [0x57, 0x45, 0x42, 0x50]) - ) { - return { extension: "webp", mimeType: "image/webp" }; - } + const detected = detectMediaType(buffer); - // AVIF: "ftypavif" at byte 4 - if (matches(buffer, 4, [0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66])) { - return { extension: "avif", mimeType: "image/avif" }; - } + if (!detected || detected.kind !== MediaKind.IMAGE) return null; - return null; + return { + extension: detected.extension as DetectedImageType["extension"], + mimeType: detected.mimeType, + }; } diff --git a/src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts b/src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts index 3054c6ee..af48f4d7 100644 --- a/src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts +++ b/src/core/use-cases/article/upload-article-cover/upload-article-cover.usecase.ts @@ -1,8 +1,6 @@ -import type { CryptoPort } from "@core/ports/services/crypto.port"; -import type { StoragePort } from "@core/ports/services/storage.port"; -import { InvalidFileTypeError, PayloadTooLargeError } from "@core/errors"; +import { MediaChannel } from "@core/domain/enums"; +import type { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; import type { UploadArticleCoverUseCaseInput } from "./upload-article-cover-usecase.input"; -import { detectImageType } from "./detect-image-type"; /** Largest cover image accepted, in bytes. */ const MAX_COVER_BYTES = 5 * 1024 * 1024; @@ -18,54 +16,40 @@ export class UploadArticleCoverUseCase { /** * Creates a new instance of UploadArticleCoverUseCase. * - * @param storageService - Object storage receiving the image - * @param cryptoService - Source of the random file name + * @param uploadModeratedMediaUseCase - Shared upload path that validates, + * moderates and records the file */ constructor( - private readonly storageService: StoragePort, - private readonly cryptoService: CryptoPort, + private readonly uploadModeratedMediaUseCase: UploadModeratedMediaUseCase, ) {} /** * Executes the upload. * - * The file name is generated, never derived from the upload: a client name - * can carry a path traversal or a second extension, and neither can survive - * a name the server invents. + * A cover is always a still, so the shared path is told to refuse video: + * the article read path has nowhere to put one, and a cover that cannot be + * cleared inside the request would leave an article with a blank header + * until a worker got to it. * * @param input - The uploader and the raw bytes * @returns The storage key of the stored image * * @throws PayloadTooLargeError - When the image exceeds the size limit - * @throws InvalidFileTypeError - When the bytes are not a supported image + * @throws InvalidMediaTypeError - When the bytes are not a supported format + * @throws InvalidFileTypeError - When the bytes are a video + * @throws MediaRejectedError - When moderation refuses the image */ async execute(input: UploadArticleCoverUseCaseInput): Promise { - if (input.truncated || input.fileBuffer.byteLength > MAX_COVER_BYTES) { - throw new PayloadTooLargeError( - "Cover image must be 5 MB or smaller.", - ); - } + const result = await this.uploadModeratedMediaUseCase.execute({ + userId: input.userId, + fileBuffer: input.fileBuffer, + channel: MediaChannel.ARTICLE_COVER, + keyPrefix: "articles/covers/" + input.userId, + truncated: input.truncated, + maxBytes: MAX_COVER_BYTES, + allowVideo: false, + }); - const detected = detectImageType(input.fileBuffer); - - if (!detected) { - throw new InvalidFileTypeError( - "Cover image must be a JPEG, PNG, GIF, WEBP or AVIF file.", - ); - } - - const key = - "articles/covers/" + - input.userId + - "/" + - this.cryptoService.generateUuid() + - "." + - detected.extension; - - return await this.storageService.upload( - key, - input.fileBuffer, - detected.mimeType, - ); + return result.storageKey; } } diff --git a/src/core/use-cases/comment/create-comment/create-comment.usecase.ts b/src/core/use-cases/comment/create-comment/create-comment.usecase.ts index 3298231d..cf77926a 100644 --- a/src/core/use-cases/comment/create-comment/create-comment.usecase.ts +++ b/src/core/use-cases/comment/create-comment/create-comment.usecase.ts @@ -9,9 +9,13 @@ import type { CommentTarget } from "@core/ports/repositories/comment.repository" import { Comment } from "@core/domain/entities/comment.entity"; import { Notification } from "@core/domain/entities/notification.entity"; import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { MediaChannel, MediaOwnerKind } from "@core/domain/enums"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import { resolveAttachableMedia } from "@core/use-cases/shared/media/resolve-attachable-media"; import { ArticleNotPublishedError, BadRequestError, + MediaNotOwnedError, NotFoundError, } from "@core/errors"; import type { CreateCommentUseCaseInput } from "./create-comment-usecase.input"; @@ -21,10 +25,15 @@ export class CreateCommentUseCase { * Creates a new CreateCommentUseCase instance * @param transactionService - Service for handling database transactions * @param realtimeService - Service for sending real-time notifications + * @param mediaAssetRepository - Repository the submitted media keys are checked against + * @param r2PublicUrl - CDN origin media URLs are served from, used to + * recover the storage key behind a submitted URL */ constructor( private readonly transactionService: TransactionPort, private readonly realtimeService: RealtimePort, + private readonly mediaAssetRepository: IMediaAssetRepository, + private readonly r2PublicUrl: string, ) {} /** @@ -77,8 +86,24 @@ export class CreateCommentUseCase { * @throws NotFoundError if the target or the parent comment is not found * @throws BadRequestError if the parent comment belongs to something else * @throws ArticleNotPublishedError if the article is still a draft + * @throws MediaNotOwnedError if a submitted media URL is not one this + * author uploaded, or was rejected by moderation + * + * @remarks + * Comment media comes off the same upload endpoint as post media, so it + * gets the same ownership check. Without it a comment would be an open + * side door into publishing any URL the client likes, which is the one + * thing the moderation pipeline has to prevent. */ async execute(input: CreateCommentUseCaseInput): Promise { + const media = await resolveAttachableMedia({ + mediaUrls: input.mediaUrls || [], + uploaderId: input.authorId, + channel: MediaChannel.POST_MEDIA, + cdnBaseUrl: this.r2PublicUrl, + mediaAssetRepository: this.mediaAssetRepository, + }); + return await this.transactionService.runInTransaction(async (ctx) => { const { target } = input; const { authorId: targetAuthorId, slug: targetSlug } = @@ -125,6 +150,8 @@ export class CreateCommentUseCase { input.authorId, input.parentId, input.mediaUrls || [], + media.isSensitive, + media.mediaStatus, ) : Comment.createForArticle( input.content, @@ -132,11 +159,28 @@ export class CreateCommentUseCase { input.authorId, input.parentId, input.mediaUrls || [], + media.isSensitive, + media.mediaStatus, ); const savedComment = await ctx.commentRepository.create(tempComment); + if (media.storageKeys.length > 0) { + // The attach is the atomic claim, not the check above it: two + // requests carrying the same key both pass that check, and only + // one can come back with every row written. + const attached = await ctx.mediaAssetRepository.attachToOwner( + media.storageKeys, + MediaOwnerKind.COMMENT, + savedComment.id, + ); + + if (attached !== media.storageKeys.length) { + throw new MediaNotOwnedError(); + } + } + // Articles derive their comment count from a relation count, so // only posts carry a counter to maintain. if (target.type === "POST") { diff --git a/src/core/use-cases/media/moderate-pending-media/index.ts b/src/core/use-cases/media/moderate-pending-media/index.ts new file mode 100644 index 00000000..d80335b2 --- /dev/null +++ b/src/core/use-cases/media/moderate-pending-media/index.ts @@ -0,0 +1,2 @@ +export * from "./moderate-pending-media.usecase"; +export * from "./moderate-pending-media.output"; diff --git a/src/core/use-cases/media/moderate-pending-media/moderate-pending-media.output.ts b/src/core/use-cases/media/moderate-pending-media/moderate-pending-media.output.ts new file mode 100644 index 00000000..8691b687 --- /dev/null +++ b/src/core/use-cases/media/moderate-pending-media/moderate-pending-media.output.ts @@ -0,0 +1,22 @@ +/** + * What one pass of the video moderation worker did. + * + * Returned rather than only logged so the scheduler can report a summary line + * per tick, in the same shape as the purge jobs. + */ +export interface ModeratePendingMediaOutput { + /** How many assets were claimed and scanned. */ + scanned: number; + + /** How many came back clean. */ + approved: number; + + /** How many were marked borderline. */ + sensitive: number; + + /** How many were refused and deleted. */ + rejected: number; + + /** How many could not be scanned at all this pass. */ + failed: number; +} diff --git a/src/core/use-cases/media/moderate-pending-media/moderate-pending-media.usecase.ts b/src/core/use-cases/media/moderate-pending-media/moderate-pending-media.usecase.ts new file mode 100644 index 00000000..7c4bc998 --- /dev/null +++ b/src/core/use-cases/media/moderate-pending-media/moderate-pending-media.usecase.ts @@ -0,0 +1,380 @@ +import type { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import { Notification } from "@core/domain/entities/notification.entity"; +import { + MediaModerationStatus, + MediaOwnerKind, + NotificationType, +} from "@core/domain/enums"; +import type { + IMediaAssetRepository, + MediaState, +} from "@core/ports/repositories/media-asset.repository"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { MediaModerationPort } from "@core/ports/services/media-moderation.port"; +import type { StoragePort } from "@core/ports/services/storage.port"; +import { toMediaUrl } from "@core/use-cases/shared/media/media-url"; +import type { ModeratePendingMediaOutput } from "./moderate-pending-media.output"; + +/** + * Tuning for one pass of the worker. + */ +export interface ModeratePendingMediaConfig { + /** Most assets to scan in one tick. */ + batchSize: number; + + /** + * How many times a scan may fail before the asset is given up on. + */ + maxAttempts: number; + + /** + * How long a claim is honoured before another tick may take the asset + * back. Bounds how long a crash mid-scan can withhold a post's media. + */ + leaseSeconds: number; + + /** CDN origin the provider fetches stored videos from. */ + r2PublicUrl: string; +} + +/** + * Use case that scans the videos waiting for a verdict. + * + * Videos cannot be checked inside the upload request: the provider has to + * fetch and sample the file, which takes far longer than a request may be held + * open. They are therefore stored immediately, withheld by the read path, and + * resolved here. + */ +export class ModeratePendingMediaUseCase { + /** + * Creates a new instance of ModeratePendingMediaUseCase. + * + * @param mediaAssetRepository - Repository the pending assets are claimed from + * @param mediaModerationService - Automated content moderation + * @param storageService - Object storage rejected files are deleted from + * @param postRepository - Repository for posts carrying scanned media + * @param commentRepository - Repository for comments carrying scanned media + * @param notificationRepository - Repository used to tell an author their media was removed + * @param moderatePendingMediaConfig - Batch size, retry budget and CDN origin + * @param logger - Service for logging operations + */ + constructor( + private readonly mediaAssetRepository: IMediaAssetRepository, + private readonly mediaModerationService: MediaModerationPort, + private readonly storageService: StoragePort, + private readonly postRepository: IPostRepository, + private readonly commentRepository: ICommentRepository, + private readonly notificationRepository: INotificationRepository, + private readonly moderatePendingMediaConfig: ModeratePendingMediaConfig, + private readonly logger: LoggerPort, + ) {} + + /** + * Scans one batch of pending videos. + * + * Assets are claimed atomically, so several API instances can run this on + * the same schedule without paying twice for the same verdict. + * + * Each asset is handled independently: one provider failure must not stop + * the rest of the batch, since the alternative is a single bad file + * blocking every video behind it. + * + * @returns How many assets were scanned and how they came out + */ + async execute(): Promise { + const claimed = await this.mediaAssetRepository.claimPending( + this.moderatePendingMediaConfig.batchSize, + this.moderatePendingMediaConfig.leaseSeconds, + ); + + const output: ModeratePendingMediaOutput = { + scanned: claimed.length, + approved: 0, + sensitive: 0, + rejected: 0, + failed: 0, + }; + + for (const asset of claimed) { + try { + const status = await this.scan(asset); + + if (status === MediaModerationStatus.REJECTED) + output.rejected++; + else if (status === MediaModerationStatus.SENSITIVE) + output.sensitive++; + else output.approved++; + } catch (error) { + output.failed++; + + // The handler writes to the database too, so it can fail on + // its own. Letting that escape would abandon every asset still + // claimed in this batch at SCANNING, which is the one state + // nothing else recovers from within a tick. + try { + await this.handleFailure(asset, error); + } catch (handlerError) { + this.logger.error( + { + context: "MediaModeration", + storageKey: asset.storageKey, + err: handlerError, + }, + "Failed to record a failed media scan.", + ); + } + } + } + + return output; + } + + /** + * Scans one asset and applies the verdict. + * + * @param asset - The claimed asset + * @returns The status the asset ended up in + */ + private async scan(asset: MediaAsset): Promise { + const publicUrl = toMediaUrl( + asset.storageKey, + this.moderatePendingMediaConfig.r2PublicUrl, + ); + + const result = + await this.mediaModerationService.moderateVideo(publicUrl); + + await this.mediaAssetRepository.recordOutcome(asset.id, { + status: result.verdict, + categories: result.categories, + scores: result.scores, + provider: result.provider, + }); + + if (result.verdict === MediaModerationStatus.REJECTED) { + this.logger.warn( + { + context: "MediaModeration", + storageKey: asset.storageKey, + uploaderId: asset.uploaderId, + categories: result.categories, + }, + "Rejected an uploaded video.", + ); + + await this.deleteFromStorage(asset); + } + + const owner = await this.refreshOwner(asset.storageKey); + + if (result.verdict === MediaModerationStatus.REJECTED) { + await this.notifyUploader(asset, owner); + } + + return result.verdict; + } + + /** + * Removes a rejected object from storage. + * + * A failure here is logged rather than raised: the verdict is already + * recorded and the read path already withholds the file, so the only cost + * of a missed delete is an orphaned object nobody has a URL for. Raising + * would instead push the asset back to PENDING and have it scanned again. + * + * @param asset - The rejected asset + */ + private async deleteFromStorage(asset: MediaAsset): Promise { + try { + await this.storageService.delete(asset.storageKey); + } catch (error) { + this.logger.error( + { + context: "MediaModeration", + storageKey: asset.storageKey, + err: error, + }, + "Failed to delete rejected media from storage.", + ); + } + } + + /** + * Rewrites the owning post or comment from its surviving assets. + * + * The asset is re-read rather than taken from the batch that was claimed, + * and that is the whole point of the method taking a key. An upload is + * routinely claimed by this worker before the post using it has been + * submitted: the claim-time snapshot then says the asset belongs to + * nobody, and trusting it would leave a post that was created seconds + * later holding media that is withheld forever - the asset now has a + * verdict, so nothing ever claims it again to try a second time. + * + * The new media list is rebuilt from the assets rather than edited in + * place. The surviving assets, in upload order, already describe exactly + * what the content should carry, and computing a removal against a row + * that may have changed underneath is how a race turns into a media list + * missing something it should have kept. + * + * @param storageKey - The key of the asset that was just scanned + * @returns The owner the verdict was written to, if there was one + */ + private async refreshOwner( + storageKey: string, + ): Promise<{ ownerId: string; ownerKind: MediaOwnerKind } | null> { + const [fresh] = await this.mediaAssetRepository.findByStorageKeys([ + storageKey, + ]); + + const ownerId = fresh?.ownerId; + const ownerKind = fresh?.ownerKind; + + if (!ownerId || !ownerKind) return null; + + const siblings = await this.mediaAssetRepository.findByOwner( + ownerKind, + ownerId, + ); + + const state: MediaState = { + mediaUrls: siblings + .filter((sibling) => sibling.isServable) + .map((sibling) => + toMediaUrl( + sibling.storageKey, + this.moderatePendingMediaConfig.r2PublicUrl, + ), + ), + isSensitive: siblings.some( + (sibling) => sibling.status === MediaModerationStatus.SENSITIVE, + ), + mediaStatus: siblings.some( + (sibling) => + sibling.status === MediaModerationStatus.PENDING || + sibling.status === MediaModerationStatus.SCANNING, + ) + ? MediaModerationStatus.PENDING + : MediaModerationStatus.APPROVED, + }; + + if (ownerKind === MediaOwnerKind.POST) { + await this.postRepository.updateMediaState(ownerId, state); + return { ownerId, ownerKind }; + } + + if (ownerKind === MediaOwnerKind.COMMENT) { + await this.commentRepository.updateMediaState(ownerId, state); + return { ownerId, ownerKind }; + } + + // An article cover is always an image, and images are scanned inside + // the upload request, so one can never reach the queue this worker + // reads. Saying so out loud beats an else that would quietly write a + // cover's verdict onto a comment if that ever stopped being true. + this.logger.error( + { + context: "MediaModeration", + storageKey, + ownerKind, + }, + "Claimed a pending asset whose owner kind cannot be pending.", + ); + + return null; + } + + /** + * Tells the uploader their media was removed. + * + * The notification is self-issued: it comes from the platform, and there + * is no system account to attribute it to. The type carries the meaning, + * so the client renders it as a moderation notice rather than as something + * another user did. + * + * A failure is logged rather than raised. The removal is the part that + * matters and it has already happened; retrying the whole scan to redeliver + * a notice would spend another provider call on a file that is already + * gone. + * + * @param asset - The rejected asset + * @param owner - The content the asset was attached to, as re-read after + * the scan; null when nothing claimed it + */ + private async notifyUploader( + asset: MediaAsset, + owner: { ownerId: string; ownerKind: MediaOwnerKind } | null, + ): Promise { + try { + await this.notificationRepository.create( + Notification.create( + asset.uploaderId, + asset.uploaderId, + NotificationType.MEDIA_REJECTED, + owner?.ownerKind === MediaOwnerKind.POST + ? { postId: owner.ownerId } + : owner?.ownerKind === MediaOwnerKind.COMMENT + ? { commentId: owner.ownerId } + : {}, + ), + ); + } catch (error) { + this.logger.error( + { + context: "MediaModeration", + storageKey: asset.storageKey, + err: error, + }, + "Failed to notify the uploader about rejected media.", + ); + } + } + + /** + * Records a failed scan and gives up once the retry budget is spent. + * + * Giving up rejects the asset rather than leaving it pending. A file that + * cannot be checked is a file nobody has vouched for, and leaving it in + * PENDING forever would hide it just as thoroughly while pretending the + * question was still open - the author would never learn to re-upload it. + * + * @param asset - The asset that could not be scanned + * @param error - What went wrong + */ + private async handleFailure( + asset: MediaAsset, + error: unknown, + ): Promise { + const message = error instanceof Error ? error.message : String(error); + + const attempts = await this.mediaAssetRepository.recordFailedAttempt( + asset.id, + message, + ); + + this.logger.error( + { + context: "MediaModeration", + storageKey: asset.storageKey, + attempts, + err: error, + }, + "Failed to scan a pending video.", + ); + + if (attempts < this.moderatePendingMediaConfig.maxAttempts) return; + + await this.mediaAssetRepository.recordOutcome(asset.id, { + status: MediaModerationStatus.REJECTED, + categories: [], + provider: null, + }); + + await this.deleteFromStorage(asset); + + const owner = await this.refreshOwner(asset.storageKey); + await this.notifyUploader(asset, owner); + } +} diff --git a/src/core/use-cases/media/upload-moderated-media/index.ts b/src/core/use-cases/media/upload-moderated-media/index.ts new file mode 100644 index 00000000..7d2b49ca --- /dev/null +++ b/src/core/use-cases/media/upload-moderated-media/index.ts @@ -0,0 +1,3 @@ +export * from "./upload-moderated-media.usecase"; +export * from "./upload-moderated-media-usecase.input"; +export * from "./upload-moderated-media-usecase.output"; diff --git a/src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.input.ts b/src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.input.ts new file mode 100644 index 00000000..e40cc4f2 --- /dev/null +++ b/src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.input.ts @@ -0,0 +1,43 @@ +import type { MediaChannel } from "@core/domain/enums"; + +/** + * Input for a moderated upload. + */ +export interface UploadModeratedMediaInput { + /** + * The unique identifier of the user uploading the file. + */ + userId: string; + + /** + * The binary content of the file. + */ + fileBuffer: Buffer; + + /** + * Which upload endpoint the file arrived through. Stored on the asset and + * checked again when content tries to attach it. + */ + channel: MediaChannel; + + /** + * Storage key prefix, without a trailing slash. The file name itself is + * always generated. + */ + keyPrefix: string; + + /** + * Whether the multipart layer cut the file short at its size limit. + */ + truncated?: boolean; + + /** + * Largest accepted size in bytes. + */ + maxBytes: number; + + /** + * Whether this endpoint accepts video. Only post media does. + */ + allowVideo: boolean; +} diff --git a/src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.output.ts b/src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.output.ts new file mode 100644 index 00000000..0d514459 --- /dev/null +++ b/src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.output.ts @@ -0,0 +1,21 @@ +import type { MediaKind, MediaModerationStatus } from "@core/domain/enums"; + +/** + * The result of a moderated upload. + */ +export interface UploadModeratedMediaOutput { + /** + * The key the file was stored under. + */ + storageKey: string; + + /** + * Whether the file went down the image path or the video path. + */ + kind: MediaKind; + + /** + * The asset's moderation state. PENDING only ever for a video. + */ + status: MediaModerationStatus; +} diff --git a/src/core/use-cases/media/upload-moderated-media/upload-moderated-media.usecase.ts b/src/core/use-cases/media/upload-moderated-media/upload-moderated-media.usecase.ts new file mode 100644 index 00000000..e95af03e --- /dev/null +++ b/src/core/use-cases/media/upload-moderated-media/upload-moderated-media.usecase.ts @@ -0,0 +1,218 @@ +import { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import { + MediaKind, + MediaModerationStatus, + type MediaModerationCategory, + type MediaModerationVerdict, +} from "@core/domain/enums"; +import { + InvalidFileTypeError, + InvalidMediaTypeError, + MediaRejectedError, + ModerationUnavailableError, + PayloadTooLargeError, +} from "@core/errors"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { + MediaModerationPort, + MediaModerationResult, +} from "@core/ports/services/media-moderation.port"; +import type { StoragePort } from "@core/ports/services/storage.port"; +import { detectMediaType } from "@core/use-cases/shared/media/detect-media-type"; +import type { UploadModeratedMediaInput } from "./upload-moderated-media-usecase.input"; +import type { UploadModeratedMediaOutput } from "./upload-moderated-media-usecase.output"; + +/** + * The verdict and its supporting detail, once an image has been cleared. + */ +interface ImageModerationVerdict { + verdict: MediaModerationVerdict; + categories: MediaModerationCategory[]; + scores: Record; + provider: string; +} + +/** + * Use case for storing an uploaded file that has been checked for forbidden + * content. + * + * Every upload endpoint on the platform goes through here, so the rules about + * what may be stored live in one place rather than being restated - and + * eventually diverging - at four call sites. + */ +export class UploadModeratedMediaUseCase { + /** + * Creates a new instance of UploadModeratedMediaUseCase. + * + * @param storageService - Object storage receiving the file + * @param mediaModerationService - Automated content moderation + * @param mediaAssetRepository - Repository recording what was stored + * @param cryptoService - Source of the generated file name + * @param logger - Service for logging operations + */ + constructor( + private readonly storageService: StoragePort, + private readonly mediaModerationService: MediaModerationPort, + private readonly mediaAssetRepository: IMediaAssetRepository, + private readonly cryptoService: CryptoPort, + private readonly logger: LoggerPort, + ) {} + + /** + * Executes the upload. + * + * The order of the steps is the whole design. An image is scanned before a + * byte reaches storage, so forbidden content is never held even briefly and + * never gets a URL, not even an unlisted one. A video cannot be treated + * that way - the provider has to fetch and sample it, which takes far + * longer than a request may be held open - so it is stored first and + * scanned by the background worker, and the read path withholds it until a + * verdict exists. + * + * The file name is generated rather than derived from the upload: a client + * name can carry a path traversal or a second extension, and neither can + * survive a name the server invents. + * + * @param input - The uploader, the raw bytes, and where they belong + * @returns The stored key and the state it was stored in + * + * @throws PayloadTooLargeError - When the file exceeds the size limit + * @throws InvalidMediaTypeError - When the bytes are not a supported format + * @throws InvalidFileTypeError - When a video reaches an image-only endpoint + * @throws MediaRejectedError - When moderation refuses the file + * @throws ModerationUnavailableError - When the provider could not be reached + */ + async execute( + input: UploadModeratedMediaInput, + ): Promise { + if (input.truncated || input.fileBuffer.byteLength > input.maxBytes) { + const megabytes = Math.floor(input.maxBytes / (1024 * 1024)); + + throw new PayloadTooLargeError( + "File must be " + megabytes + " MB or smaller.", + ); + } + + const detected = detectMediaType(input.fileBuffer); + + // The message follows the endpoint rather than the failure: telling + // someone uploading an article cover that videos are allowed would be + // a lie, and they would go and try one. + if ( + !detected || + (detected.kind === MediaKind.VIDEO && !input.allowVideo) + ) { + throw input.allowVideo + ? new InvalidMediaTypeError() + : new InvalidFileTypeError( + "Invalid file type. Only images are allowed.", + ); + } + + const storageKey = + input.keyPrefix + + "/" + + this.cryptoService.generateUuid() + + "." + + detected.extension; + + const moderation = + detected.kind === MediaKind.IMAGE + ? await this.moderateImage(input, detected.mimeType, storageKey) + : null; + + await this.storageService.upload( + storageKey, + input.fileBuffer, + detected.mimeType, + ); + + const asset = await this.mediaAssetRepository.create( + MediaAsset.create({ + storageKey, + kind: detected.kind, + mimeType: detected.mimeType, + byteSize: input.fileBuffer.byteLength, + uploaderId: input.userId, + channel: input.channel, + verdict: moderation?.verdict, + categories: moderation?.categories, + scores: moderation?.scores, + provider: moderation?.provider, + }), + ); + + return { + storageKey: asset.storageKey, + kind: detected.kind, + status: asset.status, + }; + } + + /** + * Scans an image and turns a rejection into an error. + * + * A provider that cannot be reached fails the upload rather than waving the + * file through. Letting unscanned files past during an outage would turn + * every outage into an open door, and an outage is exactly when someone + * testing the limits would try again. + * + * @param input - The upload being checked + * @param mimeType - The type detected from the bytes + * @param storageKey - The key the file would be stored under, for the log + * @returns The provider's result when the file may be stored + * + * @throws MediaRejectedError - When the file is refused + * @throws ModerationUnavailableError - When the provider could not be reached + */ + private async moderateImage( + input: UploadModeratedMediaInput, + mimeType: string, + storageKey: string, + ): Promise { + let result: MediaModerationResult; + + try { + result = await this.mediaModerationService.moderateImage( + input.fileBuffer, + mimeType, + ); + } catch (error) { + this.logger.error( + { + context: "MediaModeration", + userId: input.userId, + channel: input.channel, + error: error instanceof Error ? error.message : error, + }, + "Moderation provider unreachable; refusing the upload.", + ); + + throw new ModerationUnavailableError(); + } + + if (result.verdict === MediaModerationStatus.REJECTED) { + this.logger.warn( + { + context: "MediaModeration", + userId: input.userId, + channel: input.channel, + storageKey, + categories: result.categories, + }, + "Rejected an uploaded image.", + ); + + throw new MediaRejectedError(); + } + + return { + verdict: result.verdict, + categories: result.categories, + scores: result.scores, + provider: result.provider, + }; + } +} diff --git a/src/core/use-cases/post/create-post/create-post.usecase.ts b/src/core/use-cases/post/create-post/create-post.usecase.ts index 50c6be84..151c31af 100644 --- a/src/core/use-cases/post/create-post/create-post.usecase.ts +++ b/src/core/use-cases/post/create-post/create-post.usecase.ts @@ -7,10 +7,13 @@ import type { LoggerPort } from "@core/ports/services/logger.port"; import type { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; import type { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import type { LanguageDetectionPort } from "@core/ports/services/language-detection.port"; -import { PostType } from "@core/domain/enums"; +import { MediaChannel, MediaOwnerKind, PostType } from "@core/domain/enums"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import { resolveAttachableMedia } from "@core/use-cases/shared/media/resolve-attachable-media"; import { ForbiddenError } from "@core/errors/common/forbidden.error"; import { NotFoundError } from "@core/errors/common/not-found.error"; import { BadRequestError } from "@core/errors/common/bad-request.error"; +import { MediaNotOwnedError } from "@core/errors"; /** * Use case for creating a new post. @@ -28,6 +31,9 @@ export class CreatePostUseCase { * @param notifyNewPostUseCase - Use case that fans the post out to followers * @param notifyQuotedAuthorUseCase - Use case that tells an author their post was quoted * @param languageDetectionService - Service that labels the content with the language it was written in + * @param mediaAssetRepository - Repository the submitted media keys are checked against + * @param r2PublicUrl - CDN origin media URLs are served from, used to + * recover the storage key behind a submitted URL * @param logger - Service for logging operations */ constructor( @@ -37,6 +43,8 @@ export class CreatePostUseCase { private readonly notifyNewPostUseCase: NotifyNewPostUseCase, private readonly notifyQuotedAuthorUseCase: NotifyQuotedAuthorUseCase, private readonly languageDetectionService: LanguageDetectionPort, + private readonly mediaAssetRepository: IMediaAssetRepository, + private readonly r2PublicUrl: string, private readonly logger: LoggerPort, ) {} @@ -48,6 +56,8 @@ export class CreatePostUseCase { * @returns Promise - Resolves when post creation is complete * * @throws BadRequestError - When an empty post quotes nothing + * @throws MediaNotOwnedError - When a submitted media URL is not one this + * author uploaded, or was rejected by moderation * @throws NotFoundError - When quotedPostId names a post that does not exist * * @remarks @@ -76,6 +86,16 @@ export class CreatePostUseCase { * from holding the write open across it, and a post whose language cannot * be told is stored with a null rather than a guess. * + * Media is resolved before the transaction as well, and this is the check + * that makes moderation mean anything at all. Scanning at upload time only + * governs what the upload endpoint writes to storage; nothing stops a + * client from skipping that endpoint and putting its own URLs straight in + * this body. Requiring every URL to resolve to an asset this author + * uploaded, through the media channel, and that moderation did not reject, + * closes that path. The assets are then bound to the post inside the + * transaction, so a rolled-back write leaves no asset claiming a post that + * does not exist. + * * Followers and, for a quote, the quoted author are notified after the * post is committed, deliberately outside the caller's critical path: the * post is the thing worth keeping, so a notification failure is logged @@ -98,6 +118,14 @@ export class CreatePostUseCase { const lang = await this.languageDetectionService.detect(input.content); + const media = await resolveAttachableMedia({ + mediaUrls: input.mediaUrls || [], + uploaderId: input.authorId, + channel: MediaChannel.POST_MEDIA, + cdnBaseUrl: this.r2PublicUrl, + mediaAssetRepository: this.mediaAssetRepository, + }); + const rawPost = await this.transactionService.runInTransaction( async (ctx) => { if (input.quotedPostId) { @@ -117,10 +145,28 @@ export class CreatePostUseCase { input.categories || [], input.quotedPostId, lang, + media.isSensitive, + media.mediaStatus, ); const created = await ctx.postRepository.create(post); + if (media.storageKeys.length > 0) { + // The attach is the atomic claim, not the check above it: + // two requests carrying the same key both pass that check, + // and only one can come back with every row written. + const attached = + await ctx.mediaAssetRepository.attachToOwner( + media.storageKeys, + MediaOwnerKind.POST, + created.id, + ); + + if (attached !== media.storageKeys.length) { + throw new MediaNotOwnedError(); + } + } + if (input.quotedPostId) { await ctx.postRepository.incrementQuoteCount( input.quotedPostId, diff --git a/src/core/use-cases/post/get-posts/get-posts.usecase.ts b/src/core/use-cases/post/get-posts/get-posts.usecase.ts index 05579b72..a368c679 100644 --- a/src/core/use-cases/post/get-posts/get-posts.usecase.ts +++ b/src/core/use-cases/post/get-posts/get-posts.usecase.ts @@ -190,23 +190,51 @@ export class GetPostsUseCase { } const pageIds = snapshot.ids.slice(offset, offset + limit); - const posts = await this.postRepository.findByIds( + const hydrated = await this.postRepository.findByIds( pageIds, input.currentUserId, ); + const ranked = this.reorder(hydrated, pageIds); + + // The ranked window is narrow - a bounded pool of recent posts - and + // on a quiet feed it can hold fewer posts than the reader asked for. + // Ending the page there would tell them the feed is over while + // hundreds of older posts sit behind it, which is exactly what + // happened to Turkish readers of the community feed: two matching + // posts, then "no more posts". + const topUp = + pageIds.length < limit + ? await this.tailPosts( + input, + 0, + limit - pageIds.length, + snapshot, + ) + : []; + + const posts = [...ranked, ...topUp]; - await this.recordSeen(input.currentUserId, pageIds); + await this.recordSeen( + input.currentUserId, + posts.map((post) => post.id), + ); return { - posts: this.reorder(posts, pageIds), + posts, total: snapshot.total, - // Counted in ids consumed, not posts returned: a post deleted - // between ranking and hydration shortens the page, and advancing - // by the shorter count would serve that gap again forever. - nextCursor: encodeFeedCursor({ - token, - offset: offset + pageIds.length, - }), + // Advanced by ids consumed rather than posts returned, so a post + // deleted between ranking and hydration shortens this page instead + // of leaving a gap that is served again forever. The top-up counts + // too: the tail keeps counting in the snapshot's coordinates. + nextCursor: + pageIds.length < limit && topUp.length < limit - pageIds.length + ? // The ranked window ran out and the tail could not fill + // the rest, so there is genuinely nothing behind this. + null + : encodeFeedCursor({ + token, + offset: offset + pageIds.length + topUp.length, + }), }; } @@ -570,24 +598,27 @@ export class GetPostsUseCase { } /** - * Serves a page from beyond the ranked window. + * Reads posts from behind the ranked window, newest first. + * + * Shared by the two ways a reader reaches past the ranking: paging clean + * off the end of the snapshot, and a ranked page too short to fill the + * limit on its own. Both must skip everything the snapshot holds, or the + * reader is served a post they have already been given. * * @param input - The feed request. - * @param skip - How far past the ranked head the page starts. - * @param limit - Page size. - * @param snapshot - The snapshot, whose ids this page must not repeat. - * @param token - The token the returned cursor keeps pointing at. - * @param offset - Where this page started, in snapshot coordinates. - * @returns The page of posts, the unchanged total, and the next cursor. + * @param skip - How many tail rows have already been served this scroll. + * @param limit - How many rows to read. + * @param snapshot - The snapshot whose ids the tail must not repeat. + * @returns The posts, at most `limit` of them. */ - private async chronologicalTail( + private async tailPosts( input: GetPostsInput, skip: number, limit: number, snapshot: RankedSnapshot, - token: string, - offset: number, - ): Promise { + ): Promise { + if (limit <= 0) return []; + const { posts } = await this.postRepository.findAll({ page: 1, skip, @@ -607,6 +638,30 @@ export class GetPostsUseCase { : {}), }); + return posts; + } + + /** + * Serves a page from beyond the ranked window. + * + * @param input - The feed request. + * @param skip - How far past the ranked head the page starts. + * @param limit - Page size. + * @param snapshot - The snapshot, whose ids this page must not repeat. + * @param token - The token the returned cursor keeps pointing at. + * @param offset - Where this page started, in snapshot coordinates. + * @returns The page of posts, the unchanged total, and the next cursor. + */ + private async chronologicalTail( + input: GetPostsInput, + skip: number, + limit: number, + snapshot: RankedSnapshot, + token: string, + offset: number, + ): Promise { + const posts = await this.tailPosts(input, skip, limit, snapshot); + await this.recordSeen( input.currentUserId, posts.map((post) => post.id), @@ -615,11 +670,12 @@ export class GetPostsUseCase { return { posts, total: snapshot.total, - // The tail keeps counting in the same coordinates the ranked head - // used, so the next cursor lands one page further into the tail - // rather than back at its start. + // A short read is the end of the feed: the query asked for `limit` + // rows and the database had fewer left. A full one keeps counting + // in the snapshot's coordinates, so the next cursor lands one page + // further into the tail rather than back at its start. nextCursor: - posts.length > 0 + posts.length === limit ? encodeFeedCursor({ token, offset: offset + posts.length, diff --git a/src/core/use-cases/post/upload-post-media/upload-post-media-usecase.input.ts b/src/core/use-cases/post/upload-post-media/upload-post-media-usecase.input.ts index 86e8e4d2..250ca620 100644 --- a/src/core/use-cases/post/upload-post-media/upload-post-media-usecase.input.ts +++ b/src/core/use-cases/post/upload-post-media/upload-post-media-usecase.input.ts @@ -2,7 +2,7 @@ * Input interface for uploading post media files. * * This interface defines the required parameters for uploading media files - * (images or videos) associated with posts. + * (images or videos) associated with posts and comments. */ export interface UploadPostMediaInput { /** @@ -16,14 +16,10 @@ export interface UploadPostMediaInput { fileBuffer: Buffer; /** - * The MIME type of the file (e.g., "image/jpeg", "video/mp4"). - * Used for validation and proper handling of the file. + * Whether the multipart layer cut the file short at its size limit. + * + * A truncated file is refused rather than stored: the bytes that were cut + * off are exactly the ones moderation never got to look at. */ - mimeType: string; - - /** - * The original filename of the uploaded file. - * Used for generating the storage filename and extension detection. - */ - originalFileName: string; + truncated?: boolean; } diff --git a/src/core/use-cases/post/upload-post-media/upload-post-media.usecase.ts b/src/core/use-cases/post/upload-post-media/upload-post-media.usecase.ts index 5b5c6e1d..69b0d8c4 100644 --- a/src/core/use-cases/post/upload-post-media/upload-post-media.usecase.ts +++ b/src/core/use-cases/post/upload-post-media/upload-post-media.usecase.ts @@ -1,51 +1,53 @@ -import type { StoragePort } from "@core/ports/services/storage.port"; -import { InvalidMediaTypeError } from "@core/errors"; +import { MediaChannel } from "@core/domain/enums"; +import type { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; import type { UploadPostMediaInput } from "./upload-post-media-usecase.input"; +/** Largest post media file accepted, in bytes. */ +const MAX_POST_MEDIA_BYTES = 5 * 1024 * 1024; + /** * Use case for uploading post media files. * - * This use case handles the process of uploading media files (images and videos) - * for posts to the storage service with proper file naming and validation. + * The endpoint is shared by posts and comments, and it is the only one that + * accepts video, so it is also the only one that can hand back a file which is + * stored but not yet cleared. */ export class UploadPostMediaUseCase { /** * Creates a new instance of UploadPostMediaUseCase. * - * @param storageService - Service for file storage operations + * @param uploadModeratedMediaUseCase - Shared upload path that validates, + * moderates and records the file */ - constructor(private readonly storageService: StoragePort) {} + constructor( + private readonly uploadModeratedMediaUseCase: UploadModeratedMediaUseCase, + ) {} /** * Executes the media upload process. * - * @param input - Input containing file data, MIME type, original filename, and user ID - * @returns Promise The uploaded file path/URL + * The client's MIME type and file name are ignored entirely: both are + * attacker-controlled, and the shared upload path reads the format out of + * the bytes instead. * - * @throws InvalidMediaTypeError - When the file type is not supported + * @param input - Input containing file data and user ID + * @returns Promise The storage key the file was stored under * - * @remarks - * This method validates the file type, generates a unique filename with - * user ID and timestamp, and uploads the file to the storage service. - * Supported file types are images and videos. + * @throws InvalidMediaTypeError - When the bytes are not a supported format + * @throws MediaRejectedError - When moderation refuses the file + * @throws ModerationUnavailableError - When the provider could not be reached */ async execute(input: UploadPostMediaInput): Promise { - if ( - !input.mimeType.startsWith("image/") && - !input.mimeType.startsWith("video/") - ) { - throw new InvalidMediaTypeError(); - } - - const extension = input.originalFileName.split(".").pop() || "jpeg"; - const newFileName = `posts/${input.userId}/${Date.now()}-${Math.random().toString(36).substring(7)}.${extension}`; - - const uploadedFilePath = await this.storageService.upload( - newFileName, - input.fileBuffer, - input.mimeType, - ); + const result = await this.uploadModeratedMediaUseCase.execute({ + userId: input.userId, + fileBuffer: input.fileBuffer, + channel: MediaChannel.POST_MEDIA, + keyPrefix: "posts/" + input.userId, + truncated: input.truncated, + maxBytes: MAX_POST_MEDIA_BYTES, + allowVideo: true, + }); - return uploadedFilePath; + return result.storageKey; } } diff --git a/src/core/use-cases/profile/update-avatar/update-avatar-usecase.input.ts b/src/core/use-cases/profile/update-avatar/update-avatar-usecase.input.ts index fe1a0be5..26376376 100644 --- a/src/core/use-cases/profile/update-avatar/update-avatar-usecase.input.ts +++ b/src/core/use-cases/profile/update-avatar/update-avatar-usecase.input.ts @@ -16,14 +16,10 @@ export interface UpdateAvatarUseCaseInput { fileBuffer: Buffer; /** - * The MIME type of the image file (e.g., "image/jpeg", "image/png"). - * Used for validation and proper handling of the file. + * Whether the multipart layer cut the file short at its size limit. + * + * A truncated file is refused rather than stored: the bytes that were cut + * off are exactly the ones moderation never got to look at. */ - mimeType: string; - - /** - * The original filename of the uploaded image file. - * Used for generating the storage filename and extension detection. - */ - originalFileName: string; + truncated?: boolean; } diff --git a/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts b/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts index b9eabe84..9d6850d1 100644 --- a/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts +++ b/src/core/use-cases/profile/update-avatar/update-avatar.usecase.ts @@ -2,9 +2,13 @@ import type { IProfileRepository } from "@core/ports/repositories/profile.reposi import type { UpdateAvatarUseCaseInput } from "./update-avatar-usecase.input"; import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; -import { InvalidFileTypeError } from "@core/errors"; +import { MediaChannel } from "@core/domain/enums"; +import type { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; import { isDefaultMediaKey } from "@core/domain/constants/default-media.constants"; +/** Largest avatar accepted, in bytes. */ +const MAX_AVATAR_BYTES = 5 * 1024 * 1024; + /** * Use case for updating a user's profile avatar. * @@ -16,11 +20,14 @@ export class UpdateAvatarUseCase { * Creates a new instance of UpdateAvatarUseCase. * * @param profileRepository - Repository for managing profile data + * @param uploadModeratedMediaUseCase - Shared upload path that validates, + * moderates and records the file * @param storageService - Service for file storage operations * @param logger - Service for logging operations */ constructor( private readonly profileRepository: IProfileRepository, + private readonly uploadModeratedMediaUseCase: UploadModeratedMediaUseCase, private readonly storageService: StoragePort, private readonly logger: LoggerPort, ) {} @@ -28,40 +35,39 @@ export class UpdateAvatarUseCase { /** * Executes the avatar update process. * - * @param input - Input containing user ID, file data, MIME type, and original filename - * @returns Promise The uploaded file path/URL + * @param input - Input containing user ID and file data + * @returns Promise The uploaded file path * - * @throws InvalidFileTypeError - When the file type is not an image + * @throws InvalidMediaTypeError - When the bytes are not a supported format + * @throws InvalidFileTypeError - When the bytes are a video + * @throws MediaRejectedError - When moderation refuses the image * * @remarks - * This method validates the file type, uploads the new avatar image, - * updates the profile with the new image URL, and attempts to delete - * the old avatar image from storage. Deletion errors are logged but - * don't prevent the operation from completing successfully. + * An avatar is the most public image a user has - it travels into every + * feed, search result and notification they appear in - so it goes through + * exactly the same moderation as post media rather than a lighter check. + * + * The old avatar is deleted only after the profile points at the new one. + * Deletion errors are logged but don't prevent the operation from + * completing successfully: the profile is already correct, and an orphaned + * object costs storage rather than correctness. */ async execute(input: UpdateAvatarUseCaseInput): Promise { - if (!input.mimeType.startsWith("image/")) { - throw new InvalidFileTypeError( - "Invalid file type. Only images are allowed.", - ); - } const oldAvatarUrl = await this.profileRepository.findAvatarByUserId( input.userId, ); - const extension = input.originalFileName.split(".").pop() || "jpeg"; - const newFileName = `avatars/${input.userId}-${Date.now()}.${extension}`; + const { storageKey } = await this.uploadModeratedMediaUseCase.execute({ + userId: input.userId, + fileBuffer: input.fileBuffer, + channel: MediaChannel.AVATAR, + keyPrefix: "avatars/" + input.userId, + truncated: input.truncated, + maxBytes: MAX_AVATAR_BYTES, + allowVideo: false, + }); - const uploadedFilePath = await this.storageService.upload( - newFileName, - input.fileBuffer, - input.mimeType, - ); - - await this.profileRepository.updateAvatar( - input.userId, - uploadedFilePath, - ); + await this.profileRepository.updateAvatar(input.userId, storageKey); if (oldAvatarUrl && !isDefaultMediaKey(oldAvatarUrl)) { try { @@ -79,6 +85,6 @@ export class UpdateAvatarUseCase { } } - return uploadedFilePath; + return storageKey; } } diff --git a/src/core/use-cases/profile/update-banner/update-banner-usecase.input.ts b/src/core/use-cases/profile/update-banner/update-banner-usecase.input.ts index 3f0213ba..0f849f83 100644 --- a/src/core/use-cases/profile/update-banner/update-banner-usecase.input.ts +++ b/src/core/use-cases/profile/update-banner/update-banner-usecase.input.ts @@ -11,19 +11,15 @@ export interface UpdateBannerUseCaseInput { userId: string; /** - * The MIME type of the image file (e.g., "image/jpeg", "image/png"). - * Used for validation and proper handling of the file. - */ - mimeType: string; - - /** - * The original filename of the uploaded image file. - * Used for generating the storage filename and extension detection. + * The binary content of the image file to be uploaded as the new banner. */ - originalFileName: string; + fileBuffer: Buffer; /** - * The binary content of the image file to be uploaded as the new banner. + * Whether the multipart layer cut the file short at its size limit. + * + * A truncated file is refused rather than stored: the bytes that were cut + * off are exactly the ones moderation never got to look at. */ - fileBuffer: Buffer; + truncated?: boolean; } diff --git a/src/core/use-cases/profile/update-banner/update-banner.usecase.ts b/src/core/use-cases/profile/update-banner/update-banner.usecase.ts index 31fbd5f0..6a0674eb 100644 --- a/src/core/use-cases/profile/update-banner/update-banner.usecase.ts +++ b/src/core/use-cases/profile/update-banner/update-banner.usecase.ts @@ -1,10 +1,14 @@ import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; import type { UpdateBannerUseCaseInput } from "./update-banner-usecase.input"; -import { InvalidFileTypeError } from "@core/errors"; import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; +import { MediaChannel } from "@core/domain/enums"; +import type { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; import { isDefaultMediaKey } from "@core/domain/constants/default-media.constants"; +/** Largest banner accepted, in bytes. */ +const MAX_BANNER_BYTES = 5 * 1024 * 1024; + /** * Use case for updating a user's profile banner. * @@ -16,11 +20,14 @@ export class UpdateBannerUseCase { * Creates a new instance of UpdateBannerUseCase. * * @param profileRepository - Repository for managing profile data + * @param uploadModeratedMediaUseCase - Shared upload path that validates, + * moderates and records the file * @param storageService - Service for file storage operations * @param logger - Service for logging operations */ constructor( private readonly profileRepository: IProfileRepository, + private readonly uploadModeratedMediaUseCase: UploadModeratedMediaUseCase, private readonly storageService: StoragePort, private readonly logger: LoggerPort, ) {} @@ -28,41 +35,37 @@ export class UpdateBannerUseCase { /** * Executes the banner update process. * - * @param input - Input containing user ID, file data, MIME type, and original filename - * @returns Promise The uploaded file path/URL + * @param input - Input containing user ID and file data + * @returns Promise The uploaded file path * - * @throws InvalidFileTypeError - When the file type is not an image + * @throws InvalidMediaTypeError - When the bytes are not a supported format + * @throws InvalidFileTypeError - When the bytes are a video + * @throws MediaRejectedError - When moderation refuses the image * * @remarks - * This method validates the file type, uploads the new banner image, - * updates the profile with the new image URL, and attempts to delete - * the old banner image from storage. Deletion errors are logged but - * don't prevent the operation from completing successfully. + * A banner is shown to every visitor of the profile, so it is moderated on + * the same terms as post media. + * + * The old banner is deleted only after the profile points at the new one. + * Deletion errors are logged but don't prevent the operation from + * completing successfully. */ async execute(input: UpdateBannerUseCaseInput): Promise { - if (!input.mimeType.startsWith("image/")) { - throw new InvalidFileTypeError( - "Invalid file type. Only images are allowed.", - ); - } - const oldBannerUrl = await this.profileRepository.findBannerByUserId( input.userId, ); - const extension = input.originalFileName.split(".").pop() || "jpeg"; - const newFileName = `banners/${input.userId}-${Date.now()}.${extension}`; + const { storageKey } = await this.uploadModeratedMediaUseCase.execute({ + userId: input.userId, + fileBuffer: input.fileBuffer, + channel: MediaChannel.BANNER, + keyPrefix: "banners/" + input.userId, + truncated: input.truncated, + maxBytes: MAX_BANNER_BYTES, + allowVideo: false, + }); - const uploadedFilePath = await this.storageService.upload( - newFileName, - input.fileBuffer, - input.mimeType, - ); - - await this.profileRepository.updateBanner( - input.userId, - uploadedFilePath, - ); + await this.profileRepository.updateBanner(input.userId, storageKey); if (oldBannerUrl && !isDefaultMediaKey(oldBannerUrl)) { try { @@ -80,6 +83,6 @@ export class UpdateBannerUseCase { } } - return uploadedFilePath; + return storageKey; } } diff --git a/src/core/use-cases/shared/media/detect-media-type.ts b/src/core/use-cases/shared/media/detect-media-type.ts new file mode 100644 index 00000000..0274e1dd --- /dev/null +++ b/src/core/use-cases/shared/media/detect-media-type.ts @@ -0,0 +1,167 @@ +import { MediaKind } from "@core/domain/enums"; + +/** + * A file format this API accepts, as identified from its own bytes. + */ +export interface DetectedMediaType { + /** Which pipeline the file goes down: images are scanned inline, videos are queued. */ + kind: MediaKind; + + /** File extension to use in the storage key */ + extension: string; + + /** MIME type derived from the bytes, not from the client */ + mimeType: string; +} + +/** + * Compares a run of bytes against an expected signature. + * + * @param buffer - The uploaded bytes + * @param offset - Where the signature should start + * @param signature - The expected byte values + * @returns True when every byte matches + */ +function matches(buffer: Buffer, offset: number, signature: number[]): boolean { + if (buffer.length < offset + signature.length) return false; + + for (let i = 0; i < signature.length; i++) { + if (buffer[offset + i] !== signature[i]) return false; + } + + return true; +} + +/** + * Reads a fixed-length ASCII run out of the buffer. + * + * @param buffer - The uploaded bytes + * @param offset - Where the run starts + * @param length - How many bytes to read + * @returns The decoded string, or an empty string when the buffer is too short + */ +function ascii(buffer: Buffer, offset: number, length: number): string { + if (buffer.length < offset + length) return ""; + + return buffer.subarray(offset, offset + length).toString("latin1"); +} + +/** "ftyp" - the ISO base media file type box, at byte 4 of every MP4 family file. */ +const FTYP = [0x66, 0x74, 0x79, 0x70]; + +/** + * MP4-family brands accepted as video, mapped to what to call the result. + * + * An allow-list rather than "anything with an ftyp box": the same container + * carries AVIF and HEIC images, and a few audio-only profiles, none of which + * should be admitted by a rule about video. + */ +const VIDEO_BRANDS: Record = { + isom: { extension: "mp4", mimeType: "video/mp4" }, + iso2: { extension: "mp4", mimeType: "video/mp4" }, + iso4: { extension: "mp4", mimeType: "video/mp4" }, + iso5: { extension: "mp4", mimeType: "video/mp4" }, + iso6: { extension: "mp4", mimeType: "video/mp4" }, + mp41: { extension: "mp4", mimeType: "video/mp4" }, + mp42: { extension: "mp4", mimeType: "video/mp4" }, + avc1: { extension: "mp4", mimeType: "video/mp4" }, + mmp4: { extension: "mp4", mimeType: "video/mp4" }, + "M4V ": { extension: "m4v", mimeType: "video/x-m4v" }, + "qt ": { extension: "mov", mimeType: "video/quicktime" }, + "3gp4": { extension: "3gp", mimeType: "video/3gpp" }, + "3gp5": { extension: "3gp", mimeType: "video/3gpp" }, + "3g2a": { extension: "3g2", mimeType: "video/3gpp2" }, +}; + +/** + * Identifies an image or video by its magic bytes. + * + * The client-supplied MIME type and file name are deliberately not consulted. + * Both are attacker-controlled: a request can claim `image/png` while carrying + * an SVG, and a name like `clip.png.html` reads as an image to a naive + * extension check. Reading the bytes is the only statement about the file the + * uploader cannot forge - and it is also what decides whether the file needs + * an inline scan or a queued one, a decision no client should get to make. + * + * SVG has no signature to match and is therefore rejected for free, which is + * the intended outcome: it is a scriptable document format rather than a + * raster image, and serving one from the CDN would be a stored XSS. + * + * @param buffer - The uploaded bytes + * @returns The detected type, or null when the bytes are not a supported file + */ +export function detectMediaType(buffer: Buffer): DetectedMediaType | null { + // JPEG: FF D8 FF + if (matches(buffer, 0, [0xff, 0xd8, 0xff])) { + return { + kind: MediaKind.IMAGE, + extension: "jpg", + mimeType: "image/jpeg", + }; + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (matches(buffer, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return { + kind: MediaKind.IMAGE, + extension: "png", + mimeType: "image/png", + }; + } + + // GIF: "GIF8" + if (matches(buffer, 0, [0x47, 0x49, 0x46, 0x38])) { + return { + kind: MediaKind.IMAGE, + extension: "gif", + mimeType: "image/gif", + }; + } + + // WEBP: "RIFF" then "WEBP" at byte 8 + if ( + matches(buffer, 0, [0x52, 0x49, 0x46, 0x46]) && + matches(buffer, 8, [0x57, 0x45, 0x42, 0x50]) + ) { + return { + kind: MediaKind.IMAGE, + extension: "webp", + mimeType: "image/webp", + }; + } + + // The MP4 family. AVIF is checked first because it shares the container: + // an "ftyp" box says nothing about whether the file is a still or a clip, + // only the brand that follows does. + if (matches(buffer, 4, FTYP)) { + const brand = ascii(buffer, 8, 4); + + if (brand === "avif" || brand === "avis") { + return { + kind: MediaKind.IMAGE, + extension: "avif", + mimeType: "image/avif", + }; + } + + const video = VIDEO_BRANDS[brand]; + + if (video) { + return { kind: MediaKind.VIDEO, ...video }; + } + + return null; + } + + // WEBM / Matroska: the EBML header, 1A 45 DF A3. Both extensions share it, + // and the platform serves either as WebM. + if (matches(buffer, 0, [0x1a, 0x45, 0xdf, 0xa3])) { + return { + kind: MediaKind.VIDEO, + extension: "webm", + mimeType: "video/webm", + }; + } + + return null; +} diff --git a/src/core/use-cases/shared/media/media-url.ts b/src/core/use-cases/shared/media/media-url.ts new file mode 100644 index 00000000..d9294598 --- /dev/null +++ b/src/core/use-cases/shared/media/media-url.ts @@ -0,0 +1,49 @@ +/** + * Strips the CDN prefix off a media URL, leaving the storage key. + * + * Post and comment media travel as absolute CDN URLs, because that is the + * contract the upload endpoint has always returned and clients store them. + * Every check the platform makes is keyed on the storage key instead, so the + * two representations have to be convertible at the boundary. + * + * A value that is already a bare key is returned unchanged, which is what lets + * the same helper serve callers that never saw a URL. + * + * @param value - An absolute CDN URL or a bare storage key + * @param cdnBaseUrl - The CDN origin media is served from + * @returns The storage key, or null when the value points somewhere else + */ +export function toStorageKey(value: string, cdnBaseUrl: string): string | null { + const trimmed = value.trim(); + + if (trimmed.length === 0) return null; + + if (!/^https?:\/\//i.test(trimmed)) { + // Already a key. Reject traversal outright rather than normalising it: + // no legitimate key the platform generates contains a "..". + return trimmed.includes("..") ? null : trimmed.replace(/^\/+/, ""); + } + + const base = cdnBaseUrl.replace(/\/+$/, ""); + + if (!trimmed.startsWith(base + "/")) return null; + + // Query strings are used as cache busters on avatars and carry no meaning + // for the key itself. + const key = trimmed.slice(base.length + 1).split(/[?#]/)[0]; + + if (key.length === 0 || key.includes("..")) return null; + + return key; +} + +/** + * Builds the absolute CDN URL for a storage key. + * + * @param storageKey - The stored object key + * @param cdnBaseUrl - The CDN origin media is served from + * @returns The URL clients can fetch the object from + */ +export function toMediaUrl(storageKey: string, cdnBaseUrl: string): string { + return `${cdnBaseUrl.replace(/\/+$/, "")}/${storageKey}`; +} diff --git a/src/core/use-cases/shared/media/resolve-attachable-media.ts b/src/core/use-cases/shared/media/resolve-attachable-media.ts new file mode 100644 index 00000000..258b849e --- /dev/null +++ b/src/core/use-cases/shared/media/resolve-attachable-media.ts @@ -0,0 +1,107 @@ +import { MediaNotOwnedError } from "@core/errors"; +import { MediaModerationStatus, type MediaChannel } from "@core/domain/enums"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import { toStorageKey } from "./media-url"; + +/** + * What the caller needs to know about the media it is about to store. + */ +export interface ResolvedMedia { + /** The storage keys behind the submitted URLs, in submission order. */ + storageKeys: string[]; + + /** True when any attached asset was judged borderline. */ + isSensitive: boolean; + + /** + * The moderation state the content itself should carry: PENDING while any + * attached video is still unscanned, APPROVED otherwise. + */ + mediaStatus: MediaModerationStatus; +} + +/** + * Resolves submitted media URLs into assets this author is allowed to use. + * + * This is the check that makes moderation mean anything. Scanning at upload + * time only governs what the upload endpoint writes to storage; nothing stops + * a client from skipping that endpoint and putting its own URLs straight into + * a post body. Requiring every URL to resolve to an asset row this uploader + * created, and that moderation did not reject, closes that path. + * + * All three failure modes - unknown key, someone else's key, already rejected + * - raise the same error on purpose. Distinguishing them would turn the + * endpoint into an oracle for which keys exist. + * + * @param params - The submitted URLs, who is attaching them, and where to look + * @returns The resolved keys and the moderation state to store alongside them + * + * @throws MediaNotOwnedError - When any URL does not resolve to a usable asset + */ +export async function resolveAttachableMedia(params: { + mediaUrls: string[]; + uploaderId: string; + channel: MediaChannel; + cdnBaseUrl: string; + mediaAssetRepository: IMediaAssetRepository; +}): Promise { + const { mediaUrls, uploaderId, channel, cdnBaseUrl } = params; + + if (mediaUrls.length === 0) { + return { + storageKeys: [], + isSensitive: false, + mediaStatus: MediaModerationStatus.APPROVED, + }; + } + + const storageKeys = mediaUrls.map((url) => toStorageKey(url, cdnBaseUrl)); + + if (storageKeys.some((key) => key === null)) { + throw new MediaNotOwnedError(); + } + + const keys = storageKeys as string[]; + + const assets = await params.mediaAssetRepository.findByStorageKeys(keys); + const byKey = new Map(assets.map((asset) => [asset.storageKey, asset])); + + let isSensitive = false; + let hasPending = false; + + for (const key of keys) { + const asset = byKey.get(key); + + // An asset already claimed by other content is refused too. One + // upload backs one post: letting a key be reused would move it to the + // newest claimant, and the older post would sit waiting on a verdict + // that is being written somewhere else. + if ( + !asset || + asset.channel !== channel || + asset.ownerId !== null || + !asset.canBeAttachedBy(uploaderId) + ) { + throw new MediaNotOwnedError(); + } + + if (asset.status === MediaModerationStatus.SENSITIVE) { + isSensitive = true; + } + + if ( + asset.status === MediaModerationStatus.PENDING || + asset.status === MediaModerationStatus.SCANNING + ) { + hasPending = true; + } + } + + return { + storageKeys: keys, + isSensitive, + mediaStatus: hasPending + ? MediaModerationStatus.PENDING + : MediaModerationStatus.APPROVED, + }; +} diff --git a/src/core/use-cases/shared/media/resolve-cover-sensitivity.ts b/src/core/use-cases/shared/media/resolve-cover-sensitivity.ts new file mode 100644 index 00000000..e84504ab --- /dev/null +++ b/src/core/use-cases/shared/media/resolve-cover-sensitivity.ts @@ -0,0 +1,36 @@ +import { MediaModerationStatus } from "@core/domain/enums"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; + +/** + * Reads the moderation verdict recorded for an article cover. + * + * Deliberately softer than the check post and comment media go through, and + * for a reason those two do not share. A cover is submitted as a storage key + * under `articles/covers//`, already validated against the author's + * own prefix, so it can only ever name a file that author uploaded - there is + * no equivalent of "put any URL you like in the body" to close off here. A key + * with no asset row is a key that points at nothing in the bucket: it renders + * as a broken image, not as unmoderated content. Articles written before this + * pipeline existed are exactly that case, and refusing to load them would be + * the only thing that check bought. + * + * A forbidden cover cannot reach this point at all: covers are images, images + * are scanned inside the upload request, and a rejected one is never stored. + * The only verdict left to carry across is the middle one. + * + * @param coverImageKey - The cover's storage key, if the article has one + * @param mediaAssetRepository - Repository holding the recorded verdicts + * @returns True when the cover was judged borderline + */ +export async function resolveCoverSensitivity( + coverImageKey: string | null | undefined, + mediaAssetRepository: IMediaAssetRepository, +): Promise { + if (!coverImageKey) return false; + + const [asset] = await mediaAssetRepository.findByStorageKeys([ + coverImageKey, + ]); + + return asset?.status === MediaModerationStatus.SENSITIVE; +} diff --git a/src/http/controllers/post.controller.ts b/src/http/controllers/post.controller.ts index 54cf3590..7c8b6e00 100644 --- a/src/http/controllers/post.controller.ts +++ b/src/http/controllers/post.controller.ts @@ -118,11 +118,13 @@ export class PostController { } const fileBuffer = await part.toBuffer(); + // The client's MIME type and file name are deliberately not passed + // on: both are attacker-controlled, and the use case reads the + // format out of the bytes instead. const uploadedPath = await this.uploadPostMediaUseCase.execute({ userId, fileBuffer, - mimeType: part.mimetype, - originalFileName: part.filename, + truncated: part.file.truncated, }); uploadedUrls.push(`${r2PublicUrl}/${uploadedPath}`); @@ -197,12 +199,14 @@ export class PostController { limit, totalPages: Math.ceil(result.total / limit), nextCursor: result.nextCursor, - // A short page is the end of the feed. Comparing against the - // total would be wrong here: the ranked window is narrower - // than the total, and the total keeps moving. - hasMore: - result.nextCursor !== null && - formattedData.length === limit, + // Read straight off the cursor, never inferred from the page + // length. A short page used to be treated as the end of the + // feed, which is wrong exactly where the ranked window runs + // out mid-page: readers of a quiet feed were told there was + // nothing left while hundreds of older posts sat behind it. + // The use case knows when it has run out; it says so by + // returning no cursor. + hasMore: result.nextCursor !== null, }, }); } diff --git a/src/http/controllers/profile.controller.ts b/src/http/controllers/profile.controller.ts index 376e10e6..de28303c 100644 --- a/src/http/controllers/profile.controller.ts +++ b/src/http/controllers/profile.controller.ts @@ -107,8 +107,7 @@ export class ProfileController { const avatarUrl = await this.updateAvatarUseCase.execute({ userId, fileBuffer, - mimeType: data.mimetype, - originalFileName: data.filename, + truncated: data.file.truncated, }); reply.status(200).send({ @@ -135,8 +134,7 @@ export class ProfileController { const bannerUrl = await this.updateBannerUseCase.execute({ userId, fileBuffer, - mimeType: data.mimetype, - originalFileName: data.filename, + truncated: data.file.truncated, }); reply.status(200).send({ diff --git a/src/http/plugins/custom/media-moderation.plugin.ts b/src/http/plugins/custom/media-moderation.plugin.ts new file mode 100644 index 00000000..43e97101 --- /dev/null +++ b/src/http/plugins/custom/media-moderation.plugin.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance } from "fastify"; +import fastifyPlugin from "fastify-plugin"; + +function mediaModerationPlugin(fastify: FastifyInstance): void { + const mediaModerationScheduler = + fastify.diContainer.cradle.mediaModerationScheduler; + + fastify.addHook("onReady", () => { + mediaModerationScheduler.start(); + + fastify.log.info( + { + context: "SystemScheduler", + jobName: "MediaModeration", + status: "Started", + config: { + cronExpression: fastify.config.MEDIA_MODERATION_CRON, + batchSize: fastify.config.MEDIA_MODERATION_BATCH_SIZE, + maxAttempts: fastify.config.MEDIA_MODERATION_MAX_ATTEMPTS, + moderationEnabled: fastify.config.MODERATION_ENABLED, + }, + }, + "Media moderation scheduler initialized and started successfully.", + ); + }); + + fastify.addHook("onClose", async () => { + await mediaModerationScheduler.stop(); + + fastify.log.info( + { + context: "SystemScheduler", + jobName: "MediaModeration", + status: "Stopped", + }, + "Media moderation scheduler stopped safely.", + ); + }); +} + +export default fastifyPlugin(mediaModerationPlugin, { + name: "media-moderation-plugin", + dependencies: ["di-plugin", "prisma-plugin", "env-plugin"], +}); diff --git a/src/http/plugins/di/external.di.ts b/src/http/plugins/di/external.di.ts index bf53b201..0f3d5d95 100644 --- a/src/http/plugins/di/external.di.ts +++ b/src/http/plugins/di/external.di.ts @@ -5,6 +5,8 @@ import { GoogleAuthService } from "@infrastructure/external/google-auth.service" import { S3StorageService } from "@infrastructure/external/s3-storage.service"; import { DeepLTranslationService } from "@infrastructure/external/deepl-translation.service"; import { HeuristicLanguageDetectionService } from "@infrastructure/external/heuristic-language-detection.service"; +import { SightengineModerationService } from "@infrastructure/external/moderation/sightengine-moderation.service"; +import { NoopModerationService } from "@infrastructure/external/moderation/noop-moderation.service"; export const externalModule = { // --- Services --- @@ -42,4 +44,29 @@ export const externalModule = { languageDetectionService: asClass( HeuristicLanguageDetectionService, ).singleton(), + + /** + * Automated content moderation for uploaded media. + * + * The stand-in is chosen only when moderation is explicitly turned off - + * the test environment, and local setups without credentials. It is never + * a fallback for a provider that is down: an upload that could not be + * checked is refused rather than waved through. + */ + mediaModerationService: asFunction((config, logger) => { + if (!config.MODERATION_ENABLED) return new NoopModerationService(); + + return new SightengineModerationService( + { + apiUser: config.SIGHTENGINE_API_USER, + apiSecret: config.SIGHTENGINE_API_SECRET, + thresholds: { + reject: config.MODERATION_REJECT_THRESHOLD, + sensitive: config.MODERATION_SENSITIVE_THRESHOLD, + }, + timeoutMs: config.MODERATION_REQUEST_TIMEOUT_MS, + }, + logger, + ); + }).singleton(), }; diff --git a/src/http/plugins/di/jobs.di.ts b/src/http/plugins/di/jobs.di.ts index b9bca691..23c4b4e7 100644 --- a/src/http/plugins/di/jobs.di.ts +++ b/src/http/plugins/di/jobs.di.ts @@ -7,6 +7,8 @@ import { NotificationPurgeJob } from "@infrastructure/jobs/notification/notifica import { NotificationPurgeScheduler } from "@infrastructure/jobs/notification/notification-purge.scheduler"; import { UserInterestRebuildJob } from "@infrastructure/jobs/user-interest/user-interest-rebuild.job"; import { UserInterestRebuildScheduler } from "@infrastructure/jobs/user-interest/user-interest-rebuild.scheduler"; +import { MediaModerationJob } from "@infrastructure/jobs/media-moderation/media-moderation.job"; +import { MediaModerationScheduler } from "@infrastructure/jobs/media-moderation/media-moderation.scheduler"; export const jobsModule = { // --- Jobs --- @@ -14,6 +16,7 @@ export const jobsModule = { refreshTokenPurgeJob: asClass(RefreshTokenPurgeJob).singleton(), notificationPurgeJob: asClass(NotificationPurgeJob), userInterestRebuildJob: asClass(UserInterestRebuildJob).singleton(), + mediaModerationJob: asClass(MediaModerationJob).singleton(), // --- Schedulers --- userPurgeScheduler: asFunction((userPurgeJob, config, logger) => { @@ -48,6 +51,16 @@ export const jobsModule = { }, ), + mediaModerationScheduler: asFunction( + (mediaModerationJob, config, logger) => { + return new MediaModerationScheduler( + mediaModerationJob, + { cronExpression: config.MEDIA_MODERATION_CRON }, + logger, + ); + }, + ).singleton(), + userInterestRebuildScheduler: asFunction( (userInterestRebuildJob, config, logger) => { return new UserInterestRebuildScheduler( diff --git a/src/http/plugins/di/persistence.di.ts b/src/http/plugins/di/persistence.di.ts index 45ab0de3..b97ccd56 100644 --- a/src/http/plugins/di/persistence.di.ts +++ b/src/http/plugins/di/persistence.di.ts @@ -17,6 +17,7 @@ import { PrismaTagRepository } from "@infrastructure/persistence/repositories/pr import { PrismaArticleRepository } from "@infrastructure/persistence/repositories/prisma-article.repository"; import { PrismaArticleLikeRepository } from "@infrastructure/persistence/repositories/prisma-article-like.repository"; import { PrismaArticleBookmarkRepository } from "@infrastructure/persistence/repositories/prisma-article-bookmark.repository"; +import { PrismaMediaAssetRepository } from "@infrastructure/persistence/repositories/prisma-media-asset.repository"; /** * Dependency injection module for persistence layer @@ -28,6 +29,12 @@ import { PrismaArticleBookmarkRepository } from "@infrastructure/persistence/rep export const persistenceModule = { // --- Repositories --- + /** + * Media asset repository, backing the moderation pipeline and the + * ownership check that keeps unmoderated URLs out of stored content. + */ + mediaAssetRepository: asClass(PrismaMediaAssetRepository).singleton(), + /** * User repository for managing user data persistence * Configured with grace period settings for user data cleanup diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index ca4f858a..7a3b0993 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -37,6 +37,8 @@ import { CreatePostUseCase } from "@core/use-cases/post/create-post"; import { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; import { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media"; +import { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; +import { ModeratePendingMediaUseCase } from "@core/use-cases/media/moderate-pending-media"; import { GetPostsUseCase } from "@core/use-cases/post/get-posts"; import type { FeedRankingWeights } from "@core/use-cases/post/get-posts/feed-ranking"; import { RebuildUserInterestsUseCase } from "@core/use-cases/user-interest/rebuild-user-interests"; @@ -332,6 +334,8 @@ export const useCasesModule = { notifyNewPostUseCase, notifyQuotedAuthorUseCase, languageDetectionService, + mediaAssetRepository, + config, logger, ) => new CreatePostUseCase( @@ -341,6 +345,8 @@ export const useCasesModule = { notifyNewPostUseCase, notifyQuotedAuthorUseCase, languageDetectionService, + mediaAssetRepository, + config.R2_PUBLIC_URL, logger, ), ).singleton(), @@ -360,6 +366,45 @@ export const useCasesModule = { */ uploadPostMediaUseCase: asClass(UploadPostMediaUseCase).singleton(), + /** + * Shared upload path behind every media endpoint: byte-level type + * detection, moderation, storage and the asset record. + */ + uploadModeratedMediaUseCase: asClass( + UploadModeratedMediaUseCase, + ).singleton(), + + /** + * Background worker resolving the videos waiting for a verdict. + */ + moderatePendingMediaUseCase: asFunction( + ( + mediaAssetRepository, + mediaModerationService, + storageService, + postRepository, + commentRepository, + notificationRepository, + config, + logger, + ) => + new ModeratePendingMediaUseCase( + mediaAssetRepository, + mediaModerationService, + storageService, + postRepository, + commentRepository, + notificationRepository, + { + batchSize: config.MEDIA_MODERATION_BATCH_SIZE, + maxAttempts: config.MEDIA_MODERATION_MAX_ATTEMPTS, + leaseSeconds: config.MEDIA_MODERATION_LEASE_SECONDS, + r2PublicUrl: config.R2_PUBLIC_URL, + }, + logger, + ), + ).singleton(), + /** * Tuning weights for the feed ranker. * @@ -495,7 +540,15 @@ export const useCasesModule = { /** * Use case for creating a comment on a post */ - createCommentUseCase: asClass(CreateCommentUseCase).singleton(), + createCommentUseCase: asFunction( + (transactionService, realtimeService, mediaAssetRepository, config) => + new CreateCommentUseCase( + transactionService, + realtimeService, + mediaAssetRepository, + config.R2_PUBLIC_URL, + ), + ).singleton(), /** * */ diff --git a/src/http/types/fastify-awilix.d.ts b/src/http/types/fastify-awilix.d.ts index 910d02cf..15171dbc 100644 --- a/src/http/types/fastify-awilix.d.ts +++ b/src/http/types/fastify-awilix.d.ts @@ -19,6 +19,9 @@ import type { CachePort } from "@core/ports/services/cache.port"; import type { SeenPostsPort } from "@core/ports/services/seen-posts.port"; import type { TranslationController } from "@controllers/translation.controller"; import type { ArticleController } from "@controllers/article.controller"; +import type { MediaModerationScheduler } from "@infrastructure/jobs/media-moderation/media-moderation.scheduler"; +import type { MediaModerationPort } from "@core/ports/services/media-moderation.port"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; /** * Fastify Awilix cradle interface for dependency injection * Defines all injectable services and components available in the application @@ -87,6 +90,15 @@ declare module "@fastify/awilix" { /** Controller for article write operations */ articleController: ArticleController; + + /** Scheduler for the video moderation worker */ + mediaModerationScheduler: MediaModerationScheduler; + + /** Automated content moderation for uploaded media */ + mediaModerationService: MediaModerationPort; + + /** Repository backing the media moderation pipeline */ + mediaAssetRepository: IMediaAssetRepository; } } diff --git a/src/http/types/schemas/article/article-item.schema.ts b/src/http/types/schemas/article/article-item.schema.ts index 45305b12..56ea8b1b 100644 --- a/src/http/types/schemas/article/article-item.schema.ts +++ b/src/http/types/schemas/article/article-item.schema.ts @@ -24,6 +24,9 @@ export const ArticleItemSchema = FBType.Object({ excerpt: FBType.Union([FBType.String(), FBType.Null()]), coverImageUrl: FBType.Union([FBType.String(), FBType.Null()]), coverImageAlt: FBType.Union([FBType.String(), FBType.Null()]), + // True when moderation judged the cover borderline: the client shows it + // behind a blur rather than inline. + isSensitive: FBType.Boolean(), status: FBType.Enum(ArticleStatus), publishedAt: FBType.Union([FBType.String(), FBType.Null()]), readingTimeMinutes: FBType.Number(), diff --git a/src/http/types/schemas/comment/get-comment.schema.ts b/src/http/types/schemas/comment/get-comment.schema.ts index 69a3d7ff..220b3317 100644 --- a/src/http/types/schemas/comment/get-comment.schema.ts +++ b/src/http/types/schemas/comment/get-comment.schema.ts @@ -21,6 +21,13 @@ export const CommentItemSchema = FBType.Object({ articleId: FBType.Union([FBType.String({ format: "uuid" }), FBType.Null()]), parentId: FBType.Union([FBType.String({ format: "uuid" }), FBType.Null()]), mediaUrls: FBType.Array(FBType.String()), + // True when moderation judged the media borderline: the client shows it + // behind a blur rather than inline. + isSensitive: FBType.Boolean(), + // True while an attached video is stored but not yet cleared. mediaUrls is + // empty in the meantime; the client can say so rather than showing a post + // that looks like it lost its attachment. + mediaPending: FBType.Boolean(), createdAt: FBType.String(), likeCount: FBType.Number(), replyCount: FBType.Number(), diff --git a/src/http/types/schemas/env.schema.ts b/src/http/types/schemas/env.schema.ts index d6624ba5..eec852d4 100644 --- a/src/http/types/schemas/env.schema.ts +++ b/src/http/types/schemas/env.schema.ts @@ -68,6 +68,43 @@ export const EnvSchema = Type.Object({ FRONTEND_URL: Type.String({ default: "http://localhost:5173" }), API_URL: Type.String({ default: "http://localhost:8080" }), + // --- Media moderation --- + // Turned off in the test environment and in local setups without provider + // credentials, where a stand-in approves everything. It is never a + // fallback: a failed provider call refuses the upload rather than + // switching this off. + MODERATION_ENABLED: Type.Boolean({ default: false }), + SIGHTENGINE_API_USER: Type.String({ default: "" }), + SIGHTENGINE_API_SECRET: Type.String({ default: "" }), + // Both thresholds are a starting guess. Raw provider scores are stored on + // every asset precisely so these can be retuned against real traffic. + MODERATION_REJECT_THRESHOLD: Type.Number({ + default: 0.75, + minimum: 0, + maximum: 1, + }), + MODERATION_SENSITIVE_THRESHOLD: Type.Number({ + default: 0.4, + minimum: 0, + maximum: 1, + }), + // Bounds how long an image upload can be held open waiting on the + // provider. Past this the upload fails closed. + MODERATION_REQUEST_TIMEOUT_MS: Type.Number({ default: 15000, minimum: 1 }), + + // Runs every minute: what it clears is a user waiting to see their own + // post, and a video that takes an hour to appear reads as a broken upload. + MEDIA_MODERATION_CRON: Type.String({ default: "* * * * *" }), + MEDIA_MODERATION_BATCH_SIZE: Type.Number({ default: 10, minimum: 1 }), + // A file that cannot be checked after this many tries is refused rather + // than left pending forever, so the author learns to upload it again. + MEDIA_MODERATION_MAX_ATTEMPTS: Type.Number({ default: 3, minimum: 1 }), + // How long a worker's claim on an asset is honoured. A process killed + // mid-scan - a redeploy, an OOM - leaves the asset claimed, and without a + // lease the post carrying it would withhold its media forever. Comfortably + // longer than a scan so a slow one is not stolen from the worker doing it. + MEDIA_MODERATION_LEASE_SECONDS: Type.Number({ default: 600, minimum: 1 }), + // --- Feed ranking --- // The right values here are an empirical question, so they are configured // rather than compiled in: the mix can be retuned without a deploy. @@ -84,8 +121,12 @@ export const EnvSchema = Type.Object({ FEED_WEIGHT_ENGAGEMENT: Type.Number({ default: 0.6 }), FEED_HALF_LIFE_HOURS: Type.Number({ default: 18, minimum: 1 }), FEED_MAX_POSTS_PER_AUTHOR: Type.Number({ default: 3, minimum: 1 }), + // A ceiling on content the reader cannot read, not a target: it applies + // only while there is content in their own language to spend the other + // slots on. When there is none the feed serves what exists rather than + // running dry. FEED_FOREIGN_LANGUAGE_QUOTA: Type.Number({ - default: 0.25, + default: 0.2, minimum: 0, maximum: 1, }), diff --git a/src/http/types/schemas/post/get-post.schema.ts b/src/http/types/schemas/post/get-post.schema.ts index 7f31b996..3563cd28 100644 --- a/src/http/types/schemas/post/get-post.schema.ts +++ b/src/http/types/schemas/post/get-post.schema.ts @@ -20,6 +20,13 @@ export const QuotedPostSchema = FBType.Object({ id: FBType.String({ format: "uuid" }), content: FBType.String(), mediaUrls: FBType.Array(FBType.String()), + // True when moderation judged the media borderline: the client shows it + // behind a blur rather than inline. + isSensitive: FBType.Boolean(), + // True while an attached video is stored but not yet cleared. mediaUrls is + // empty in the meantime; the client can say so rather than showing a post + // that looks like it lost its attachment. + mediaPending: FBType.Boolean(), createdAt: FBType.String(), author: PostAuthorSchema, }); @@ -31,6 +38,13 @@ export const PostItemSchema = FBType.Object({ content: FBType.String(), type: FBType.Enum(PostType), mediaUrls: FBType.Array(FBType.String()), + // True when moderation judged the media borderline: the client shows it + // behind a blur rather than inline. + isSensitive: FBType.Boolean(), + // True while an attached video is stored but not yet cleared. mediaUrls is + // empty in the meantime; the client can say so rather than showing a post + // that looks like it lost its attachment. + mediaPending: FBType.Boolean(), createdAt: FBType.String(), likeCount: FBType.Number(), commentCount: FBType.Number(), diff --git a/src/infrastructure/external/logger.service.ts b/src/infrastructure/external/logger.service.ts index 8a625af5..e253ae14 100644 --- a/src/infrastructure/external/logger.service.ts +++ b/src/infrastructure/external/logger.service.ts @@ -7,4 +7,8 @@ export class LoggerService implements LoggerPort { error(object: object, message: string): void { return this.logger.error(object, message); } + + warn(object: object, message: string): void { + return this.logger.warn(object, message); + } } diff --git a/src/infrastructure/external/moderation/noop-moderation.service.ts b/src/infrastructure/external/moderation/noop-moderation.service.ts new file mode 100644 index 00000000..1e45e6da --- /dev/null +++ b/src/infrastructure/external/moderation/noop-moderation.service.ts @@ -0,0 +1,54 @@ +import { MediaModerationStatus } from "@core/domain/enums"; +import type { + MediaModerationPort, + MediaModerationResult, +} from "@core/ports/services/media-moderation.port"; + +/** Identifies this stand-in in stored results. */ +const PROVIDER = "noop"; + +/** + * A moderation port that approves everything. + * + * Selected when `MODERATION_ENABLED` is false, which is the case in the test + * environment and in any local setup without provider credentials. Tests must + * not depend on a third-party service being reachable, and a developer running + * the API on a laptop should not have to hold an API key to upload a picture. + * + * It is deliberately not a fallback: production selects this only if someone + * explicitly turned moderation off, never because a call failed. A failed call + * refuses the upload instead. + */ +export class NoopModerationService implements MediaModerationPort { + /** + * Approves the image without looking at it. + * + * @returns A clean verdict + */ + moderateImage(): Promise { + return Promise.resolve(this.approve()); + } + + /** + * Approves the video without looking at it. + * + * @returns A clean verdict + */ + moderateVideo(): Promise { + return Promise.resolve(this.approve()); + } + + /** + * Builds the clean verdict shared by both methods. + * + * @returns A result carrying no scores and no categories + */ + private approve(): MediaModerationResult { + return { + verdict: MediaModerationStatus.APPROVED, + categories: [], + scores: {}, + provider: PROVIDER, + }; + } +} diff --git a/src/infrastructure/external/moderation/score-to-verdict.ts b/src/infrastructure/external/moderation/score-to-verdict.ts new file mode 100644 index 00000000..55d13ef1 --- /dev/null +++ b/src/infrastructure/external/moderation/score-to-verdict.ts @@ -0,0 +1,154 @@ +import { + MediaModerationCategory, + MediaModerationStatus, + type MediaModerationVerdict, +} from "@core/domain/enums"; + +/** + * The two cut-offs that turn provider scores into a decision. + */ +export interface ModerationThresholds { + /** At or above this, a rejecting class refuses the file outright. */ + reject: number; + + /** At or above this, any flagged class marks the media sensitive. */ + sensitive: number; +} + +/** + * How far a class is allowed to escalate. + * + * `REJECT` classes can refuse an upload; `SENSITIVE` ones can only ever ask + * for a blur, however confident the provider is. + */ +enum ClassTier { + REJECT = "REJECT", + SENSITIVE = "SENSITIVE", +} + +interface ClassRule { + category: MediaModerationCategory; + tier: ClassTier; +} + +/** + * Maps a provider class onto a domain category and the worst it may do. + * + * The tiers encode a judgement about this platform rather than about the + * models. Weapons and depicted violence sit at SENSITIVE because a developer + * network is full of game screenshots, and a filter that deletes those is a + * filter people route around. Gore, sexual content, self-harm and hate imagery + * sit at REJECT because there is no reading of them that belongs in a feed. + */ +const CLASS_RULES: Record = { + "nudity.sexual_activity": { + category: MediaModerationCategory.SEXUAL_ACTIVITY, + tier: ClassTier.REJECT, + }, + "nudity.sexual_display": { + category: MediaModerationCategory.NUDITY, + tier: ClassTier.REJECT, + }, + "nudity.erotica": { + category: MediaModerationCategory.NUDITY, + tier: ClassTier.REJECT, + }, + "nudity.very_suggestive": { + category: MediaModerationCategory.SUGGESTIVE, + tier: ClassTier.SENSITIVE, + }, + "nudity.suggestive": { + category: MediaModerationCategory.SUGGESTIVE, + tier: ClassTier.SENSITIVE, + }, + "nudity.mildly_suggestive": { + category: MediaModerationCategory.SUGGESTIVE, + tier: ClassTier.SENSITIVE, + }, + "gore.prob": { + category: MediaModerationCategory.GORE, + tier: ClassTier.REJECT, + }, + "self-harm.prob": { + category: MediaModerationCategory.SELF_HARM, + tier: ClassTier.REJECT, + }, + "offensive.prob": { + category: MediaModerationCategory.OFFENSIVE, + tier: ClassTier.REJECT, + }, + "violence.prob": { + category: MediaModerationCategory.VIOLENCE, + tier: ClassTier.SENSITIVE, + }, + "weapon.prob": { + category: MediaModerationCategory.WEAPON, + tier: ClassTier.SENSITIVE, + }, +}; + +/** + * The result of reading a set of scores. + */ +export interface ScoreVerdict { + verdict: MediaModerationVerdict; + + /** Every category that met at least the sensitive threshold. */ + categories: MediaModerationCategory[]; +} + +/** + * Turns provider class scores into a verdict. + * + * Kept as a pure function, separate from the HTTP client, because the + * thresholds are the part that will actually get retuned: they can be pinned + * by unit tests and moved from the environment without anyone touching the + * code that talks to the provider. + * + * Unknown classes are ignored rather than treated as clean or as suspect - a + * provider adding a model should not silently start rejecting uploads, nor + * should it look like the file was checked for something it was not. + * + * @param scores - Flattened `class -> probability` pairs from the provider + * @param thresholds - The reject and sensitive cut-offs + * @returns The verdict and the categories behind it + */ +export function scoreToVerdict( + scores: Record, + thresholds: ModerationThresholds, +): ScoreVerdict { + const categories = new Set(); + let rejected = false; + + for (const [key, rule] of Object.entries(CLASS_RULES)) { + const score = scores[key]; + + if (typeof score !== "number" || Number.isNaN(score)) continue; + + if (score >= thresholds.reject && rule.tier === ClassTier.REJECT) { + rejected = true; + categories.add(rule.category); + continue; + } + + if (score >= thresholds.sensitive) { + categories.add(rule.category); + } + } + + if (rejected) { + return { + verdict: MediaModerationStatus.REJECTED, + categories: [...categories], + }; + } + + if (categories.size > 0) { + return { + verdict: MediaModerationStatus.SENSITIVE, + categories: [...categories], + }; + } + + return { verdict: MediaModerationStatus.APPROVED, categories: [] }; +} diff --git a/src/infrastructure/external/moderation/sightengine-moderation.service.ts b/src/infrastructure/external/moderation/sightengine-moderation.service.ts new file mode 100644 index 00000000..a6c53de2 --- /dev/null +++ b/src/infrastructure/external/moderation/sightengine-moderation.service.ts @@ -0,0 +1,209 @@ +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { + MediaModerationPort, + MediaModerationResult, +} from "@core/ports/services/media-moderation.port"; +import { + flattenSightengineFrames, + flattenSightengineScores, +} from "./sightengine-response"; +import { scoreToVerdict, type ModerationThresholds } from "./score-to-verdict"; + +/** Identifies this provider in stored results. */ +const PROVIDER = "sightengine"; + +/** Base URL of the Sightengine REST API. */ +const API_BASE = "https://api.sightengine.com/1.0"; + +/** + * The models asked for on every call. + * + * `nudity-2.1` supersedes the older nudity model and reports graded classes + * rather than a single number, which is what makes a "blur this" verdict + * possible at all. + */ +const MODELS = "nudity-2.1,gore-2.0,violence,weapon,self-harm,offensive"; + +/** + * Credentials and tuning for the Sightengine client. + */ +export interface SightengineConfig { + apiUser: string; + apiSecret: string; + thresholds: ModerationThresholds; + timeoutMs: number; +} + +/** + * Sightengine implementation of the media moderation port. + * + * One provider covers both stills and video, and video can be handed over as a + * URL, which is the reason it was chosen over the alternatives: the platform + * stores media in Cloudflare R2, and the providers that only accept video from + * their own cloud's object storage would have meant copying every upload into + * a second bucket purely to have it looked at. + */ +export class SightengineModerationService implements MediaModerationPort { + /** + * Creates a new instance of SightengineModerationService. + * + * @param config - Credentials, thresholds and the request timeout + * @param logger - Service for logging operations + */ + constructor( + private readonly config: SightengineConfig, + private readonly logger: LoggerPort, + ) { + // Fails the boot rather than every upload. Without this a deploy that + // forgot the secrets comes up healthy and answers 503 to each image + // for as long as nobody notices - the failure is correct but silent, + // and it looks like the provider is down rather than unconfigured. + if (!config.apiUser || !config.apiSecret) { + throw new Error( + "MODERATION_ENABLED is true but SIGHTENGINE_API_USER / " + + "SIGHTENGINE_API_SECRET are not set.", + ); + } + } + + /** + * Scans a still image by uploading its bytes. + * + * @param buffer - The image bytes + * @param mimeType - The MIME type detected from those bytes + * @returns The provider's verdict + * + * @throws Error - When the provider is unreachable or answers with a failure + */ + async moderateImage( + buffer: Buffer, + mimeType: string, + ): Promise { + const form = new FormData(); + + // Copied into a plain Uint8Array: a Node Buffer can sit on a + // SharedArrayBuffer, which BlobPart does not accept. + form.append( + "media", + new Blob([new Uint8Array(buffer)], { type: mimeType }), + "upload", + ); + form.append("models", MODELS); + form.append("api_user", this.config.apiUser); + form.append("api_secret", this.config.apiSecret); + + const payload = await this.send(API_BASE + "/check.json", form); + + return this.toResult(flattenSightengineScores(payload)); + } + + /** + * Scans a stored video by URL. + * + * Uses the synchronous endpoint rather than the callback one: a webhook + * would need a publicly reachable, signature-verified route on this API + * purely to receive a result the background worker is already waiting for. + * The worker is not holding a request open, so it can afford to block. + * + * @param publicUrl - Publicly reachable URL of the stored video + * @returns The provider's verdict + * + * @throws Error - When the provider is unreachable or answers with a failure + */ + async moderateVideo(publicUrl: string): Promise { + const form = new FormData(); + + form.append("stream_url", publicUrl); + form.append("models", MODELS); + form.append("api_user", this.config.apiUser); + form.append("api_secret", this.config.apiSecret); + + const payload = await this.send( + API_BASE + "/video/check-sync.json", + form, + ); + + const data = payload.data as { frames?: unknown } | undefined; + const frames = Array.isArray(data?.frames) + ? (data.frames as Record[]) + : []; + + if (frames.length === 0) { + throw new Error("Sightengine returned no frames for the video."); + } + + return this.toResult(flattenSightengineFrames(frames)); + } + + /** + * Posts a form to the provider and returns the decoded body. + * + * A non-2xx response, a `status: "failure"` body and a timeout are all + * raised as errors rather than being folded into a clean verdict. The + * callers fail closed on an error, and a provider that answered "I could + * not look at this" must not be mistaken for one that answered "this is + * fine". + * + * @param url - The endpoint to call + * @param form - The multipart body + * @returns The decoded response body + * + * @throws Error - When the call fails or the provider reports a failure + */ + private async send( + url: string, + form: FormData, + ): Promise> { + const response = await fetch(url, { + method: "POST", + body: form, + signal: AbortSignal.timeout(this.config.timeoutMs), + }); + + if (!response.ok) { + throw new Error( + "Sightengine responded with HTTP " + response.status + ".", + ); + } + + const payload = (await response.json()) as Record; + + if (payload.status === "failure") { + const error = payload.error as { message?: string } | undefined; + + throw new Error( + "Sightengine reported a failure: " + + (error?.message ?? "unknown error"), + ); + } + + return payload; + } + + /** + * Applies the thresholds and logs anything that was not clean. + * + * @param scores - The flattened class scores + * @returns The port-level result + */ + private toResult(scores: Record): MediaModerationResult { + const { verdict, categories } = scoreToVerdict( + scores, + this.config.thresholds, + ); + + if (categories.length > 0) { + this.logger.warn( + { + context: "MediaModeration", + provider: PROVIDER, + verdict, + categories, + }, + "Moderation flagged a file.", + ); + } + + return { verdict, categories, scores, provider: PROVIDER }; + } +} diff --git a/src/infrastructure/external/moderation/sightengine-response.ts b/src/infrastructure/external/moderation/sightengine-response.ts new file mode 100644 index 00000000..b620e83b --- /dev/null +++ b/src/infrastructure/external/moderation/sightengine-response.ts @@ -0,0 +1,127 @@ +/** + * Flattens a Sightengine response into `class -> probability` pairs. + * + * The provider nests its answer differently per model - `nudity` is a flat map + * of classes, `gore` and `violence` carry a single `prob`, and `weapon` has + * changed shape across model versions - so the shape is normalised once here + * and everything downstream reads plain dotted keys. + */ + +/** + * The keys under `nudity` that are class probabilities. `none` and `context` + * describe the absence of a match and are deliberately not carried through. + */ +const NUDITY_CLASSES = [ + "sexual_activity", + "sexual_display", + "erotica", + "very_suggestive", + "suggestive", + "mildly_suggestive", +] as const; + +/** Models that answer with a single probability under `prob`. */ +const PROB_MODELS = ["gore", "violence", "self-harm", "offensive"] as const; + +/** + * Reads a number out of an unknown value, or null when it is not one. + * + * @param value - The value to read + * @returns The number, or null + */ +function asNumber(value: unknown): number | null { + return typeof value === "number" && !Number.isNaN(value) ? value : null; +} + +/** + * Reads a plain object out of an unknown value. + * + * @param value - The value to read + * @returns The object, or null + */ +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Flattens one Sightengine result object - a whole image response, or a single + * video frame - into dotted class keys. + * + * @param result - The provider's result object + * @returns Flattened `class -> probability` pairs + */ +export function flattenSightengineScores( + result: Record, +): Record { + const scores: Record = {}; + + const nudity = asRecord(result.nudity); + + if (nudity) { + for (const cls of NUDITY_CLASSES) { + const score = asNumber(nudity[cls]); + if (score !== null) scores["nudity." + cls] = score; + } + } + + for (const model of PROB_MODELS) { + const section = asRecord(result[model]); + const score = section ? asNumber(section.prob) : null; + + if (score !== null) scores[model + ".prob"] = score; + } + + // The weapon model reports per-class probabilities in its current version + // and a single number in the older one. The highest class is what matters + // either way, so both shapes collapse to one key. + const weapon = result.weapon; + const flatWeapon = asNumber(weapon); + + if (flatWeapon !== null) { + scores["weapon.prob"] = flatWeapon; + } else { + const weaponClasses = asRecord(asRecord(weapon)?.classes); + + if (weaponClasses) { + const values = Object.values(weaponClasses) + .map(asNumber) + .filter((value): value is number => value !== null); + + if (values.length > 0) { + scores["weapon.prob"] = Math.max(...values); + } + } + } + + return scores; +} + +/** + * Reduces a video's per-frame results to the worst score seen for each class. + * + * A clip is exactly as acceptable as its worst frame: content that appears for + * a second is still published, and averaging across frames would let a long + * clean stretch bury it. + * + * @param frames - The frame results from a video response + * @returns Flattened `class -> highest probability across frames` pairs + */ +export function flattenSightengineFrames( + frames: Record[], +): Record { + const worst: Record = {}; + + for (const frame of frames) { + for (const [key, score] of Object.entries( + flattenSightengineScores(frame), + )) { + if (worst[key] === undefined || score > worst[key]) { + worst[key] = score; + } + } + } + + return worst; +} diff --git a/src/infrastructure/jobs/media-moderation/media-moderation.job.ts b/src/infrastructure/jobs/media-moderation/media-moderation.job.ts new file mode 100644 index 00000000..196599ac --- /dev/null +++ b/src/infrastructure/jobs/media-moderation/media-moderation.job.ts @@ -0,0 +1,27 @@ +import type { + ModeratePendingMediaOutput, + ModeratePendingMediaUseCase, +} from "@core/use-cases/media/moderate-pending-media"; + +/** + * Background job that resolves the videos waiting for a moderation verdict. + */ +export class MediaModerationJob { + /** + * Creates a new instance of MediaModerationJob. + * + * @param moderatePendingMediaUseCase - Use case that scans one batch + */ + constructor( + private readonly moderatePendingMediaUseCase: ModeratePendingMediaUseCase, + ) {} + + /** + * Runs one pass. + * + * @returns What the pass did, for the scheduler's log line + */ + async run(): Promise { + return await this.moderatePendingMediaUseCase.execute(); + } +} diff --git a/src/infrastructure/jobs/media-moderation/media-moderation.scheduler.ts b/src/infrastructure/jobs/media-moderation/media-moderation.scheduler.ts new file mode 100644 index 00000000..9b40dbee --- /dev/null +++ b/src/infrastructure/jobs/media-moderation/media-moderation.scheduler.ts @@ -0,0 +1,85 @@ +import type { FastifyBaseLogger } from "fastify"; +import cron, { type ScheduledTask } from "node-cron"; +import type { MediaModerationJob } from "./media-moderation.job"; + +export interface MediaModerationSchedulerOptions { + cronExpression: string; +} + +/** + * Runs the video moderation job on a cron schedule. + * + * Ticks far more often than the purge jobs do, because what it clears is a + * user waiting to see their own post: a video that takes an hour to appear + * reads as a broken upload rather than as a check in progress. + */ +export class MediaModerationScheduler { + private task?: ScheduledTask; + + /** + * Creates a new instance of MediaModerationScheduler. + * + * @param job - The job to run on each tick + * @param options - The cron expression to run it on + * @param logger - Fastify logger + */ + constructor( + private readonly job: MediaModerationJob, + private readonly options: MediaModerationSchedulerOptions, + private readonly logger: FastifyBaseLogger, + ) {} + + /** + * Starts the schedule. Calling it twice is a no-op. + */ + start(): void { + if (this.task) return; + + this.task = cron.schedule(this.options.cronExpression, () => { + void (async (): Promise => { + try { + const result = await this.job.run(); + + // A tick that found nothing is the normal case and would + // otherwise fill the log every minute. + if (result.scanned === 0) return; + + this.logger.info( + { + job: "media-moderation", + ...result, + cronExpression: this.options.cronExpression, + }, + "Media moderation pass completed", + ); + } catch (error) { + this.logger.error( + { + job: "media-moderation", + error, + }, + "Media moderation pass failed", + ); + } + })(); + }); + + this.logger.info("Media Moderation Scheduler initialized"); + } + + /** + * Stops the schedule. + * + * Destroys the task rather than only dropping the reference. This one + * ticks every minute, so a schedule left running past `onClose` fires + * against a disconnected Prisma client - and does so inside every E2E run. + */ + async stop(): Promise { + if (!this.task) return; + + const task = this.task; + this.task = undefined; + + await task.destroy(); + } +} diff --git a/src/infrastructure/persistence/database/transaction.service.ts b/src/infrastructure/persistence/database/transaction.service.ts index f759b992..bdfc0836 100644 --- a/src/infrastructure/persistence/database/transaction.service.ts +++ b/src/infrastructure/persistence/database/transaction.service.ts @@ -14,6 +14,7 @@ import { PrismaBookmarkRepository } from "../repositories/prisma-bookmark.reposi import { PrismaVerificationTokenRepository } from "../repositories/prisma-verification-token.repository"; import { PrismaArticleRepository } from "../repositories/prisma-article.repository"; import { PrismaArticleLikeRepository } from "../repositories/prisma-article-like.repository"; +import { PrismaMediaAssetRepository } from "../repositories/prisma-media-asset.repository"; /** * Transaction service implementation for managing database transactions @@ -57,6 +58,7 @@ export class TransactionService implements TransactionPort { new PrismaVerificationTokenRepository(tx), articleRepository: new PrismaArticleRepository(tx), articleLikeRepository: new PrismaArticleLikeRepository(tx), + mediaAssetRepository: new PrismaMediaAssetRepository(tx), }; return await work(context); diff --git a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts index 08f569e7..b974a02e 100644 --- a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts @@ -27,6 +27,8 @@ export interface ArticleResponse { excerpt: string | null; coverImageUrl: string | null; coverImageAlt: string | null; + /** True when the client should blur the cover behind a tap. */ + isSensitive: boolean; status: string; publishedAt: Date | null; readingTimeMinutes: number; @@ -83,6 +85,7 @@ export class ArticlePrismaMapper { excerpt: dbArticle.excerpt, coverImageKey: dbArticle.coverImageKey, coverImageAlt: dbArticle.coverImageAlt, + isSensitive: dbArticle.isSensitive, status: dbArticle.status as ArticleStatus, publishedAt: dbArticle.publishedAt, readingTimeMinutes: dbArticle.readingTimeMinutes, @@ -126,6 +129,7 @@ export class ArticlePrismaMapper { excerpt: article.excerpt, coverImageKey: article.coverImageKey, coverImageAlt: article.coverImageAlt, + isSensitive: article.isSensitive, status: article.status, publishedAt: article.publishedAt, readingTimeMinutes: article.readingTimeMinutes, @@ -174,6 +178,9 @@ export class ArticlePrismaMapper { ? `${cdnUrl}/${article.coverImageKey}` : null, coverImageAlt: article.coverImageAlt, + // True when moderation judged the cover borderline; the client + // blurs it behind a tap. + isSensitive: article.isSensitive, status: article.status, publishedAt: article.publishedAt, readingTimeMinutes: article.readingTimeMinutes, diff --git a/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts b/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts index 251b0b59..362ea428 100644 --- a/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts @@ -1,5 +1,6 @@ import type { Prisma } from "@generated/prisma/client"; import { Comment } from "@core/domain/entities/comment.entity"; +import { MediaModerationStatus } from "@core/domain/enums"; export type CommentWithRelations = Prisma.CommentGetPayload<{ include: { @@ -32,6 +33,10 @@ export interface CommentResponse { articleId: string | null; mediaUrls: string[]; + /** True when the client should blur the media behind a tap. */ + isSensitive: boolean; + /** True while the media is stored but not yet cleared by moderation. */ + mediaPending: boolean; parentId: string | null; createdAt: Date; author: { @@ -63,6 +68,8 @@ export class CommentPrismaMapper { authorId: dbComment.authorId, parentId: dbComment.parentId, mediaUrls: dbComment.mediaUrls, + isSensitive: dbComment.isSensitive, + mediaStatus: dbComment.mediaStatus as MediaModerationStatus, createdAt: dbComment.createdAt, updatedAt: dbComment.updatedAt, @@ -100,7 +107,11 @@ export class CommentPrismaMapper { postId: comment.postId, articleId: comment.articleId, parentId: comment.parentId, - mediaUrls: comment.mediaUrls, + // Media that has not been cleared is withheld rather than the whole + // comment: the text is the author's and was never in question. + mediaUrls: comment.isMediaServable ? comment.mediaUrls : [], + isSensitive: comment.isSensitive, + mediaPending: comment.mediaStatus === MediaModerationStatus.PENDING, createdAt: comment.createdAt, likeCount: comment.likeCount, replyCount: comment.replyCount, diff --git a/src/infrastructure/persistence/mappers/media-asset-prisma.mapper.ts b/src/infrastructure/persistence/mappers/media-asset-prisma.mapper.ts new file mode 100644 index 00000000..a4e9e118 --- /dev/null +++ b/src/infrastructure/persistence/mappers/media-asset-prisma.mapper.ts @@ -0,0 +1,85 @@ +import type { MediaAsset as PrismaMediaAsset } from "@generated/prisma/client"; +import { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import type { + MediaKind, + MediaModerationCategory, + MediaModerationStatus, + MediaChannel, + MediaOwnerKind, +} from "@core/domain/enums"; + +/** + * Two-way mapper between the `media_assets` table and the domain entity. + * + * There is no `toResponse`: an asset row is internal bookkeeping. What the API + * exposes is the media URL on the post or comment that carries it, and the + * scores behind a verdict are exactly the kind of detail that helps someone + * work out what slips past the filter. + */ +export class MediaAssetPrismaMapper { + /** + * Maps a database row to the domain entity. + * + * The enum casts are safe because the domain enums mirror the Prisma ones + * value for value, which is why they were written that way. + * + * @param row - The Prisma media asset row + * @returns The instantiated MediaAsset domain entity + */ + public static toDomain(row: PrismaMediaAsset): MediaAsset { + return MediaAsset.with({ + id: row.id, + storageKey: row.storageKey, + kind: row.kind as unknown as MediaKind, + mimeType: row.mimeType, + byteSize: row.byteSize, + uploaderId: row.uploaderId, + channel: row.channel as unknown as MediaChannel, + ownerId: row.ownerId, + ownerKind: row.ownerKind as unknown as MediaOwnerKind | null, + status: row.status as unknown as MediaModerationStatus, + categories: row.categories as MediaModerationCategory[], + scores: (row.scores as Record | null) ?? null, + provider: row.provider, + moderatedAt: row.moderatedAt, + attempts: row.attempts, + lastError: row.lastError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + } + + /** + * Maps a domain entity to the shape Prisma needs to insert it. + * + * @param asset - The asset to persist + * @returns The create payload + */ + public static toPrismaCreate(asset: MediaAsset): { + storageKey: string; + kind: MediaKind; + mimeType: string; + byteSize: number; + uploaderId: string; + channel: MediaChannel; + status: MediaModerationStatus; + categories: string[]; + scores: Record | undefined; + provider: string | null; + moderatedAt: Date | null; + } { + return { + storageKey: asset.storageKey, + kind: asset.kind, + mimeType: asset.mimeType, + byteSize: asset.byteSize, + uploaderId: asset.uploaderId, + channel: asset.channel, + status: asset.status, + categories: asset.categories, + scores: asset.scores ?? undefined, + provider: asset.provider, + moderatedAt: asset.moderatedAt, + }; + } +} diff --git a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts index 01d02184..0852e769 100644 --- a/src/infrastructure/persistence/mappers/post-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/post-prisma.mapper.ts @@ -1,5 +1,6 @@ import { Post } from "@core/domain/entities/post.entity"; import type { PostCategory } from "@core/domain/enums/post-category-enum"; +import { MediaModerationStatus } from "@core/domain/enums/media-moderation-status.enum"; import type { PostType } from "@core/domain/enums/post-type.enum"; import type { Prisma } from "@generated/prisma/client"; @@ -42,6 +43,10 @@ export interface QuotedPostResponse { id: string; content: string; mediaUrls: string[]; + /** True when the client should blur the media behind a tap. */ + isSensitive: boolean; + /** True while the media is stored but not yet cleared by moderation. */ + mediaPending: boolean; createdAt: Date; author: { id: string; @@ -69,6 +74,10 @@ export interface PostResponse { }; isLiked: boolean; isBookmarked: boolean; + /** True when the client should blur the media behind a tap. */ + isSensitive: boolean; + /** True while the media is stored but not yet cleared by moderation. */ + mediaPending: boolean; /** Detected content language, null when the detector could not tell. */ lang: string | null; tags?: { name: string }[]; @@ -111,12 +120,17 @@ export class PostPrismaMapper { isBookmarked: dbPost.bookmarks && dbPost.bookmarks.length > 0, categories: (dbPost.category as PostCategory[]) || [], lang: dbPost.lang, + isSensitive: dbPost.isSensitive, + mediaStatus: dbPost.mediaStatus as MediaModerationStatus, quotedPostId: dbPost.quotedPostId ?? undefined, quotedPost: dbPost.quotedPost ? { id: dbPost.quotedPost.id, content: dbPost.quotedPost.content, mediaUrls: dbPost.quotedPost.mediaUrls, + isSensitive: dbPost.quotedPost.isSensitive, + mediaStatus: dbPost.quotedPost + .mediaStatus as MediaModerationStatus, createdAt: dbPost.quotedPost.createdAt, author: { id: dbPost.quotedPost.authorId, @@ -147,6 +161,8 @@ export class PostPrismaMapper { category: PostCategory[]; lang: string | null; quotedPostId: string | null; + isSensitive: boolean; + mediaStatus: MediaModerationStatus; } { return { content: post.content, @@ -156,6 +172,8 @@ export class PostPrismaMapper { category: post.categories || [], lang: post.lang, quotedPostId: post.quotedPostId ?? null, + isSensitive: post.isSensitive, + mediaStatus: post.mediaStatus, }; } @@ -186,7 +204,12 @@ export class PostPrismaMapper { id: post.id, content: post.content, type: post.type, - mediaUrls: post.mediaUrls, + // Media that has not been cleared is withheld rather than the whole + // post: the text is the author's and was never in question, and a + // video usually clears within a minute of being uploaded. + mediaUrls: post.isMediaServable ? post.mediaUrls : [], + isSensitive: post.isSensitive, + mediaPending: post.mediaStatus === MediaModerationStatus.PENDING, createdAt: post.createdAt, likeCount: post.likeCount || 0, commentCount: post.commentCount || 0, @@ -227,10 +250,18 @@ export class PostPrismaMapper { const quoted = post.quotedPost; if (!quoted) return null; + const quotedStatus = + quoted.mediaStatus ?? MediaModerationStatus.APPROVED; + return { id: quoted.id, content: quoted.content, - mediaUrls: quoted.mediaUrls, + mediaUrls: + quotedStatus === MediaModerationStatus.APPROVED + ? quoted.mediaUrls + : [], + isSensitive: quoted.isSensitive ?? false, + mediaPending: quotedStatus === MediaModerationStatus.PENDING, createdAt: quoted.createdAt, author: { id: quoted.author.id, diff --git a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts index c8aa164d..0a6259cb 100644 --- a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts @@ -1,3 +1,4 @@ +import type { MediaState } from "@core/ports/repositories/media-asset.repository"; /** * Prisma implementation of the comment repository * Handles database operations for comments and nested comment relationships @@ -35,6 +36,8 @@ export class PrismaCommentRepository implements ICommentRepository { articleId: comment.articleId, authorId: comment.authorId, parentId: comment.parentId, + isSensitive: comment.isSensitive, + mediaStatus: comment.mediaStatus, }, include: { author: { @@ -211,6 +214,27 @@ export class PrismaCommentRepository implements ICommentRepository { async delete(id: string): Promise { await this.prisma.comment.delete({ where: { id } }); } + /** + * Overwrites the media state written by moderation. + * + * @param id - The id of the content to update + * @param state - The media list and moderation flags to store + */ + async updateMediaState(id: string, state: MediaState): Promise { + // updateMany, so content deleted while its video was in flight matches + // nothing instead of raising. An update that throws here would send the + // worker down the retry path and eventually reject - and delete - media + // whose owner is already gone, then tell the author about it. + await this.prisma.comment.updateMany({ + where: { id }, + data: { + mediaUrls: state.mediaUrls, + isSensitive: state.isSensitive, + mediaStatus: state.mediaStatus, + }, + }); + } + /** * Counts the number of replies for a specific parent comment * @param parentId - The ID of the parent comment diff --git a/src/infrastructure/persistence/repositories/prisma-media-asset.repository.ts b/src/infrastructure/persistence/repositories/prisma-media-asset.repository.ts new file mode 100644 index 00000000..28268962 --- /dev/null +++ b/src/infrastructure/persistence/repositories/prisma-media-asset.repository.ts @@ -0,0 +1,227 @@ +import type { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import { MediaModerationStatus, type MediaOwnerKind } from "@core/domain/enums"; +import type { + IMediaAssetRepository, + MediaModerationOutcome, +} from "@core/ports/repositories/media-asset.repository"; +import type { MediaAsset as PrismaMediaAsset } from "@generated/prisma/client"; +import { Prisma } from "@generated/prisma/client"; +import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; +import { MediaAssetPrismaMapper } from "../mappers/media-asset-prisma.mapper"; + +/** + * Prisma implementation of the media asset repository. + */ +export class PrismaMediaAssetRepository implements IMediaAssetRepository { + /** + * Initializes the PrismaMediaAssetRepository. + * + * @param prisma - The Prisma transactional client instance used for database operations. + */ + constructor(private readonly prisma: PrismaTransactionalClient) {} + + /** + * Persists a newly uploaded asset. + * + * @param asset - The asset to store + * @returns The stored asset, with its generated id + */ + async create(asset: MediaAsset): Promise { + const created = await this.prisma.mediaAsset.create({ + data: MediaAssetPrismaMapper.toPrismaCreate(asset), + }); + + return MediaAssetPrismaMapper.toDomain(created); + } + + /** + * Looks up assets by their storage keys. + * + * @param storageKeys - The keys to resolve + * @returns The assets that exist, in no particular order + */ + async findByStorageKeys(storageKeys: string[]): Promise { + if (storageKeys.length === 0) return []; + + const rows = await this.prisma.mediaAsset.findMany({ + where: { storageKey: { in: storageKeys } }, + }); + + return rows.map((row) => MediaAssetPrismaMapper.toDomain(row)); + } + + /** + * Claims up to `limit` unscanned assets for this process. + * + * Written as raw SQL because the claim has to be one statement. Reading + * the pending rows and then updating them would leave a window in which a + * second instance reads the same rows, and the API is deliberately run as + * several instances. `FOR UPDATE SKIP LOCKED` is what makes concurrent + * workers step over each other's rows rather than block on them. + * + * The lease is what makes a crash recoverable: SCANNING rows older than it + * are treated as abandoned and claimed again. `updated_at` is written by + * this statement, so it doubles as the claim timestamp. + * + * @param limit - Most assets to claim in one batch + * @param leaseSeconds - How long a claim is honoured before it is reclaimed + * @returns The claimed assets, already moved to SCANNING + */ + async claimPending( + limit: number, + leaseSeconds: number, + ): Promise { + if (limit <= 0) return []; + + const rows = await this.prisma.$queryRaw( + Prisma.sql` + UPDATE media_assets + SET status = 'SCANNING'::"MediaModerationStatus", + updated_at = NOW() + WHERE id IN ( + SELECT id + FROM media_assets + WHERE status = 'PENDING'::"MediaModerationStatus" + OR ( + status = 'SCANNING'::"MediaModerationStatus" + AND updated_at < + NOW() - (${leaseSeconds} * INTERVAL '1 second') + ) + ORDER BY created_at ASC + LIMIT ${limit} + FOR UPDATE SKIP LOCKED + ) + RETURNING + id, + storage_key AS "storageKey", + kind, + mime_type AS "mimeType", + byte_size AS "byteSize", + uploader_id AS "uploaderId", + channel, + owner_id AS "ownerId", + owner_kind AS "ownerKind", + status, + categories, + scores, + provider, + moderated_at AS "moderatedAt", + attempts, + last_error AS "lastError", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + ); + + return rows.map((row) => MediaAssetPrismaMapper.toDomain(row)); + } + + /** + * Records the verdict for a scanned asset. + * + * @param id - The asset's id + * @param outcome - The verdict and its supporting detail + */ + async recordOutcome( + id: string, + outcome: MediaModerationOutcome, + ): Promise { + await this.prisma.mediaAsset.update({ + where: { id }, + data: { + status: outcome.status, + categories: outcome.categories, + scores: outcome.scores ?? undefined, + provider: outcome.provider, + moderatedAt: new Date(), + lastError: null, + }, + }); + } + + /** + * Releases an asset back to PENDING after a failed attempt. + * + * The message is truncated: a provider can return an HTML error page, and + * a column holding one of those per failure is not worth the space. + * + * @param id - The asset's id + * @param error - What went wrong, for operators reading the table later + * @returns The attempt count after the increment + */ + async recordFailedAttempt(id: string, error: string): Promise { + const updated = await this.prisma.mediaAsset.update({ + where: { id }, + data: { + status: MediaModerationStatus.PENDING, + attempts: { increment: 1 }, + lastError: error.slice(0, 500), + }, + select: { attempts: true }, + }); + + return updated.attempts; + } + + /** + * Binds assets to the content that now uses them. + * + * The `ownerId: null` filter is the guard: it makes the claim itself the + * atomic step, so a key another request attached a moment earlier matches + * nothing and the count comes back short. + * + * @param storageKeys - The keys being attached + * @param ownerKind - Whether a post or a comment is claiming them + * @param ownerId - The id of that post or comment + * @returns How many assets were attached + */ + async attachToOwner( + storageKeys: string[], + ownerKind: MediaOwnerKind, + ownerId: string, + ): Promise { + if (storageKeys.length === 0) return 0; + + const { count } = await this.prisma.mediaAsset.updateMany({ + where: { storageKey: { in: storageKeys }, ownerId: null }, + data: { ownerKind, ownerId }, + }); + + return count; + } + + /** + * Releases every asset attached to one piece of content. + * + * @param ownerKind - Whether the owner is a post, comment or article + * @param ownerId - The owner's id + */ + async detachFromOwner( + ownerKind: MediaOwnerKind, + ownerId: string, + ): Promise { + await this.prisma.mediaAsset.updateMany({ + where: { ownerKind, ownerId }, + data: { ownerKind: null, ownerId: null }, + }); + } + + /** + * Lists every asset attached to one piece of content, oldest first. + * + * @param ownerKind - Whether the owner is a post or a comment + * @param ownerId - The owner's id + * @returns The attached assets, oldest first + */ + async findByOwner( + ownerKind: MediaOwnerKind, + ownerId: string, + ): Promise { + const rows = await this.prisma.mediaAsset.findMany({ + where: { ownerKind, ownerId }, + orderBy: { createdAt: "asc" }, + }); + + return rows.map((row) => MediaAssetPrismaMapper.toDomain(row)); + } +} diff --git a/src/infrastructure/persistence/repositories/prisma-post.repository.ts b/src/infrastructure/persistence/repositories/prisma-post.repository.ts index 5687657b..79b988e6 100644 --- a/src/infrastructure/persistence/repositories/prisma-post.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-post.repository.ts @@ -1,3 +1,4 @@ +import type { MediaState } from "@core/ports/repositories/media-asset.repository"; import type { IPostRepository, GetPostsParams, @@ -350,6 +351,27 @@ export class PrismaPostRepository implements IPostRepository { }); } + /** + * Overwrites the media state written by moderation. + * + * @param id - The id of the content to update + * @param state - The media list and moderation flags to store + */ + async updateMediaState(id: string, state: MediaState): Promise { + // updateMany, so content deleted while its video was in flight matches + // nothing instead of raising. An update that throws here would send the + // worker down the retry path and eventually reject - and delete - media + // whose owner is already gone, then tell the author about it. + await this.prisma.post.updateMany({ + where: { id }, + data: { + mediaUrls: state.mediaUrls, + isSensitive: state.isSensitive, + mediaStatus: state.mediaStatus, + }, + }); + } + /** * Increments the quote count for a post by its unique identifier. * @param postId - The unique identifier of the post that was quoted. diff --git a/tests/e2e/post/get-feed.test.ts b/tests/e2e/post/get-feed.test.ts index 478441eb..e205bf80 100644 --- a/tests/e2e/post/get-feed.test.ts +++ b/tests/e2e/post/get-feed.test.ts @@ -234,6 +234,44 @@ describe("GET /posts - Get Post Feed", () => { ).toBeGreaterThan(0); }); + it("should keep the feed going past the ranked window", async () => { + // The production bug this guards: a reader whose language matched only + // a couple of posts was handed those and told the feed was over, while + // hundreds of older ones sat behind the ranked window. + type FeedBody = { + data: { id: string }[]; + meta: { nextCursor: string | null; hasMore: boolean }; + }; + + const response = await authRequest(accessToken, { + method: "GET", + url: "/posts?limit=10", + }); + expect(response.statusCode).toBe(200); + + const body = parseBody(response); + + // Either a full page, or a short one that honestly says it is the end. + if (body.meta.hasMore) { + expect(body.data).toHaveLength(10); + expect(body.meta.nextCursor).not.toBeNull(); + } else { + expect(body.meta.nextCursor).toBeNull(); + } + }); + + it("should report hasMore from the cursor, not from the page length", async () => { + const response = await authRequest(accessToken, { + method: "GET", + url: "/posts?limit=10", + }); + const body = parseBody<{ + meta: { nextCursor: string | null; hasMore: boolean }; + }>(response); + + expect(body.meta.hasMore).toBe(body.meta.nextCursor !== null); + }); + it("should not show a signed-in reader the same post twice across builds", async () => { // Publishing retires the ranked pointer, so the second read rebuilds // rather than replaying a cached order - which is exactly when the diff --git a/tests/e2e/post/media-moderation.test.ts b/tests/e2e/post/media-moderation.test.ts new file mode 100644 index 00000000..645ded68 --- /dev/null +++ b/tests/e2e/post/media-moderation.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { request, authRequest, parseBody } from "../setup"; + +type ErrorEnvelope = { title: string; status: number; detail: string }; + +const ts = Date.now(); +const user = { + email: `mod-${ts}@media-moderation-test.com`, + password: "password123", + username: `md${ts}`, +}; + +let accessToken: string; + +const BOUNDARY = "----mediamoderationboundary"; + +/** + * Builds a multipart body carrying one file. + * + * The filename and content type travel separately from the bytes so a test can + * lie about both, which is the whole point: every upload endpoint has to decide + * from the bytes alone. + */ +function multipart( + bytes: Buffer, + filename: string, + contentType: string, +): Buffer { + const header = Buffer.from( + `--${BOUNDARY}\r\n` + + `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + + `Content-Type: ${contentType}\r\n\r\n`, + ); + const footer = Buffer.from(`\r\n--${BOUNDARY}--\r\n`); + + return Buffer.concat([header, bytes, footer]); +} + +const MULTIPART_HEADERS = { + "content-type": `multipart/form-data; boundary=${BOUNDARY}`, +}; + +beforeAll(async () => { + await request({ method: "POST", url: "/auth/register", payload: user }); + + const login = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + + accessToken = parseBody<{ data: { accessToken: string } }>(login).data + .accessToken; +}); + +/** + * These cover the parts of the moderation pipeline that need neither a storage + * backend nor a provider: the format check that runs before anything is + * uploaded, and the ownership check that runs when content claims a key. + * + * The happy path needs a live R2 connection and stays out of scope here, as it + * already does for the other upload endpoints. + */ +describe("Media moderation guards", () => { + describe("POST /media - format checks before storage", () => { + it("should reject an SVG that claims to be a PNG", async () => { + // Nothing reaches storage: the type is read from the bytes, and an + // SVG served from the CDN would be a stored XSS. + const svg = Buffer.from( + '', + ); + + const response = await authRequest(accessToken, { + method: "POST", + url: "/media", + headers: MULTIPART_HEADERS, + payload: multipart(svg, "photo.png", "image/png"), + }); + + expect(response.statusCode).toBe(415); + expect(parseBody(response).title).toBe( + "InvalidMediaTypeError", + ); + }); + + it("should reject HTML behind a video filename and content type", async () => { + const html = Buffer.from( + "", + ); + + const response = await authRequest(accessToken, { + method: "POST", + url: "/media", + headers: MULTIPART_HEADERS, + payload: multipart(html, "clip.mp4", "video/mp4"), + }); + + expect(response.statusCode).toBe(415); + }); + + it("should reject an empty file", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/media", + headers: MULTIPART_HEADERS, + payload: multipart(Buffer.alloc(0), "photo.png", "image/png"), + }); + + expect(response.statusCode).toBe(415); + }); + }); + + describe("POST /posts - media a client did not upload", () => { + it("should refuse a media URL pointing at another origin", async () => { + // This is the check that makes moderation mean anything: scanning + // at upload time governs the upload endpoint, not what a client + // puts in a post body. + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "look at this", + mediaUrls: ["https://evil.example.com/whatever.jpg"], + }, + }); + + expect(response.statusCode).toBe(400); + expect(parseBody(response).title).toBe( + "MediaNotOwnedError", + ); + }); + + it("should refuse a CDN-shaped URL that no upload produced", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { + content: "look at this", + mediaUrls: [ + "https://pub-2e6c13927ac24d548fd5b783e3cdaeb5.r2.dev/posts/someone/else.jpg", + ], + }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should still accept a post with no media at all", async () => { + const response = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "just text" }, + }); + + expect(response.statusCode).toBe(201); + }); + }); + + describe("POST /posts/:postId/comments - the same rule for comments", () => { + let postId: string; + + beforeAll(async () => { + const created = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "a post to comment on" }, + }); + + postId = parseBody<{ data: { id: string } }>(created).data.id; + }); + + it("should refuse a media URL the commenter did not upload", async () => { + // Comment media comes off the same endpoint as post media, so + // leaving it unchecked would be an open side door. + const response = await authRequest(accessToken, { + method: "POST", + url: `/posts/${postId}/comments`, + payload: { + content: "look at this", + mediaUrls: ["https://evil.example.com/whatever.jpg"], + }, + }); + + expect(response.statusCode).toBe(400); + expect(parseBody(response).title).toBe( + "MediaNotOwnedError", + ); + }); + }); + + describe("read path", () => { + it("should report the moderation flags on a post", async () => { + const created = await authRequest(accessToken, { + method: "POST", + url: "/posts", + payload: { content: "a plain text post" }, + }); + + const body = parseBody<{ + data: { isSensitive: boolean; mediaPending: boolean }; + }>(created); + + // A text-only post is clean and has nothing waiting on a scan; the + // client needs both flags present to know it can render inline. + expect(body.data.isSensitive).toBe(false); + expect(body.data.mediaPending).toBe(false); + }); + }); +}); diff --git a/tests/integration/persistence/repositories/prisma-media-asset.repository.test.ts b/tests/integration/persistence/repositories/prisma-media-asset.repository.test.ts new file mode 100644 index 00000000..cf74e79b --- /dev/null +++ b/tests/integration/persistence/repositories/prisma-media-asset.repository.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { PrismaClient } from "../../../../src/generated/prisma/client"; +import { PrismaUserRepository } from "../../../../src/infrastructure/persistence/repositories/prisma-user.repository"; +import { PrismaMediaAssetRepository } from "../../../../src/infrastructure/persistence/repositories/prisma-media-asset.repository"; +import { MediaAsset } from "../../../../src/core/domain/entities/media-asset.entity"; +import { + MediaChannel, + MediaKind, + MediaModerationCategory, + MediaModerationStatus, + MediaOwnerKind, +} from "../../../../src/core/domain/enums"; +import { createPrismaClient } from "../../helpers/setup"; + +const EMAIL_DOMAIN = "@media-asset-test.com"; + +/** Long enough that nothing in this suite is reclaimed mid-test. */ +const LEASE_SECONDS = 600; + +describe("PrismaMediaAssetRepository (integration)", () => { + let prisma: PrismaClient; + let repository: PrismaMediaAssetRepository; + let userId: string; + let otherUserId: string; + let keyCounter = 0; + + /** + * Stores a video asset in the given state. + */ + async function store( + status = MediaModerationStatus.PENDING, + uploader = userId, + ): Promise { + keyCounter++; + + return await repository.create( + MediaAsset.create({ + storageKey: `posts/${uploader}/${keyCounter}.mp4`, + kind: MediaKind.VIDEO, + mimeType: "video/mp4", + byteSize: 1024, + uploaderId: uploader, + channel: MediaChannel.POST_MEDIA, + verdict: + status === MediaModerationStatus.PENDING + ? undefined + : (status as never), + }), + ); + } + + beforeAll(async () => { + prisma = createPrismaClient(); + + const userRepository = new PrismaUserRepository(prisma, { + gracePeriodDays: 30, + }); + + const user = await userRepository.create({ + email: `owner${EMAIL_DOMAIN}`, + username: "media_owner", + passwordHash: "hashed", + }); + userId = user.id; + + const other = await userRepository.create({ + email: `other${EMAIL_DOMAIN}`, + username: "media_other", + passwordHash: "hashed", + }); + otherUserId = other.id; + + repository = new PrismaMediaAssetRepository(prisma); + }); + + beforeEach(async () => { + await prisma.mediaAsset.deleteMany({}); + }); + + afterAll(async () => { + await prisma.mediaAsset.deleteMany({}); + await prisma.user.deleteMany({ + where: { email: { endsWith: EMAIL_DOMAIN } }, + }); + await prisma.$disconnect(); + }); + + it("should store an image with its verdict already in hand", async () => { + const asset = await repository.create( + MediaAsset.create({ + storageKey: "posts/img/clean.jpg", + kind: MediaKind.IMAGE, + mimeType: "image/jpeg", + byteSize: 512, + uploaderId: userId, + channel: MediaChannel.POST_MEDIA, + verdict: MediaModerationStatus.SENSITIVE, + categories: [MediaModerationCategory.SUGGESTIVE], + scores: { "nudity.suggestive": 0.61 }, + provider: "sightengine", + }), + ); + + expect(asset.status).toBe(MediaModerationStatus.SENSITIVE); + expect(asset.categories).toEqual([MediaModerationCategory.SUGGESTIVE]); + expect(asset.scores).toEqual({ "nudity.suggestive": 0.61 }); + }); + + it("should refuse to store the same storage key twice", async () => { + const build = (): MediaAsset => + MediaAsset.create({ + storageKey: "posts/dupe/one.mp4", + kind: MediaKind.VIDEO, + mimeType: "video/mp4", + byteSize: 1, + uploaderId: userId, + channel: MediaChannel.POST_MEDIA, + }); + + await repository.create(build()); + + // A key resolving to two uploaders or two verdicts would make the + // ownership check meaningless. + await expect(repository.create(build())).rejects.toThrow(); + }); + + it("should look assets up by their storage keys", async () => { + const stored = await store(); + + const found = await repository.findByStorageKeys([ + stored.storageKey, + "posts/nobody/missing.mp4", + ]); + + expect(found).toHaveLength(1); + expect(found[0].storageKey).toBe(stored.storageKey); + expect(found[0].uploaderId).toBe(userId); + }); + + it("should return nothing for an empty key list without querying", async () => { + await expect(repository.findByStorageKeys([])).resolves.toEqual([]); + }); + + describe("claimPending", () => { + it("should claim pending assets and move them to SCANNING", async () => { + await store(); + await store(); + + const claimed = await repository.claimPending(10, LEASE_SECONDS); + + expect(claimed).toHaveLength(2); + expect( + claimed.every( + (asset) => asset.status === MediaModerationStatus.SCANNING, + ), + ).toBe(true); + }); + + it("should leave assets that already have a verdict alone", async () => { + await store(MediaModerationStatus.APPROVED); + await store(MediaModerationStatus.REJECTED); + + await expect(repository.claimPending(10, LEASE_SECONDS)).resolves.toEqual([]); + }); + + it("should claim the oldest first and honour the batch size", async () => { + const first = await store(); + await store(); + await store(); + + const claimed = await repository.claimPending(1, LEASE_SECONDS); + + expect(claimed).toHaveLength(1); + expect(claimed[0].storageKey).toBe(first.storageKey); + }); + + it("should never hand the same asset to two concurrent workers", async () => { + // The API runs as several instances. Reading pending rows and then + // updating them would leave a window where both read the same ones + // and spend two provider calls on one verdict. + await Promise.all([store(), store(), store(), store()]); + + const [a, b] = await Promise.all([ + repository.claimPending(4, LEASE_SECONDS), + repository.claimPending(4, LEASE_SECONDS), + ]); + + const ids = [...a, ...b].map((asset) => asset.id); + + expect(new Set(ids).size).toBe(ids.length); + expect(ids).toHaveLength(4); + }); + + it("should reclaim an asset stranded past its lease", async () => { + // A process killed between claiming and recording leaves the row + // in SCANNING. Without the lease nothing selects it again and the + // post carrying it withholds its media forever. + const stranded = await store(); + await repository.claimPending(1, LEASE_SECONDS); + + await prisma.mediaAsset.update({ + where: { id: stranded.id }, + data: { updatedAt: new Date(Date.now() - 60 * 60 * 1000) }, + }); + + const reclaimed = await repository.claimPending(1, LEASE_SECONDS); + + expect(reclaimed).toHaveLength(1); + expect(reclaimed[0].id).toBe(stranded.id); + }); + + it("should leave a fresh claim to the worker that holds it", async () => { + await store(); + await repository.claimPending(1, LEASE_SECONDS); + + await expect( + repository.claimPending(1, LEASE_SECONDS), + ).resolves.toEqual([]); + }); + + it("should return nothing for a non-positive limit", async () => { + await store(); + + await expect(repository.claimPending(0, LEASE_SECONDS)).resolves.toEqual([]); + }); + }); + + it("should record an outcome and clear the previous error", async () => { + const asset = await store(); + + await repository.recordFailedAttempt(asset.id, "provider down"); + await repository.recordOutcome(asset.id, { + status: MediaModerationStatus.APPROVED, + categories: [], + scores: { "gore.prob": 0.01 }, + provider: "sightengine", + }); + + const row = await prisma.mediaAsset.findUniqueOrThrow({ + where: { id: asset.id }, + }); + + expect(row.status).toBe(MediaModerationStatus.APPROVED); + expect(row.lastError).toBeNull(); + expect(row.moderatedAt).not.toBeNull(); + }); + + it("should count attempts and release the asset back to PENDING", async () => { + const asset = await store(); + await repository.claimPending(1, LEASE_SECONDS); + + await expect( + repository.recordFailedAttempt(asset.id, "timeout"), + ).resolves.toBe(1); + await expect( + repository.recordFailedAttempt(asset.id, "timeout"), + ).resolves.toBe(2); + + const row = await prisma.mediaAsset.findUniqueOrThrow({ + where: { id: asset.id }, + }); + + // Back in the queue rather than stuck in SCANNING, or a crashed worker + // would strand every asset it had claimed. + expect(row.status).toBe(MediaModerationStatus.PENDING); + expect(row.lastError).toBe("timeout"); + }); + + it("should attach assets to their owner and read them back in upload order", async () => { + const first = await store(); + const second = await store(); + await store(MediaModerationStatus.PENDING, otherUserId); + + await repository.attachToOwner( + [first.storageKey, second.storageKey], + MediaOwnerKind.POST, + "post-abc", + ); + + const attached = await repository.findByOwner( + MediaOwnerKind.POST, + "post-abc", + ); + + expect(attached.map((asset) => asset.storageKey)).toEqual([ + first.storageKey, + second.storageKey, + ]); + }); + + it("should do nothing when attaching an empty key list", async () => { + await expect( + repository.attachToOwner([], MediaOwnerKind.POST, "post-abc"), + ).resolves.toBe(0); + }); + + it("should refuse to attach an asset another owner already claimed", async () => { + // The attach is the atomic claim: two posts submitting the same key + // both pass the ownership check, and the count is what tells the + // loser it lost. + const asset = await store(); + + await expect( + repository.attachToOwner( + [asset.storageKey], + MediaOwnerKind.POST, + "post-first", + ), + ).resolves.toBe(1); + + await expect( + repository.attachToOwner( + [asset.storageKey], + MediaOwnerKind.POST, + "post-second", + ), + ).resolves.toBe(0); + + const [stored] = await repository.findByStorageKeys([ + asset.storageKey, + ]); + expect(stored.ownerId).toBe("post-first"); + }); + + it("should release every asset attached to one owner", async () => { + const asset = await store(); + await repository.attachToOwner( + [asset.storageKey], + MediaOwnerKind.POST, + "post-abc", + ); + + await repository.detachFromOwner(MediaOwnerKind.POST, "post-abc"); + + await expect( + repository.findByOwner(MediaOwnerKind.POST, "post-abc"), + ).resolves.toEqual([]); + }); +}); diff --git a/tests/unit/core/domain/enums/enums.test.ts b/tests/unit/core/domain/enums/enums.test.ts index f1b70110..17acab15 100644 --- a/tests/unit/core/domain/enums/enums.test.ts +++ b/tests/unit/core/domain/enums/enums.test.ts @@ -82,8 +82,12 @@ describe("Domain Enums", () => { expect(NotificationType.QUOTE).toBe("QUOTE"); }); - it("should have exactly 7 values", () => { - expect(Object.keys(NotificationType)).toHaveLength(7); + it("should have MEDIA_REJECTED value", () => { + expect(NotificationType.MEDIA_REJECTED).toBe("MEDIA_REJECTED"); + }); + + it("should have exactly 8 values", () => { + expect(Object.keys(NotificationType)).toHaveLength(8); }); }); diff --git a/tests/unit/core/use-cases/article/create-article.usecase.test.ts b/tests/unit/core/use-cases/article/create-article.usecase.test.ts index ded756a8..28f8a090 100644 --- a/tests/unit/core/use-cases/article/create-article.usecase.test.ts +++ b/tests/unit/core/use-cases/article/create-article.usecase.test.ts @@ -6,6 +6,8 @@ import type { Article } from "@core/domain/entities/article.entity"; import { ArticleStatus } from "@core/domain/enums/article-status.enum"; import { PostCategory } from "@core/domain/enums/post-category-enum"; import { BadRequestError } from "@core/errors"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import { MediaModerationStatus, MediaOwnerKind } from "@core/domain/enums"; const AUTHOR = "11111111-1111-4111-8111-111111111111"; const OTHER = "22222222-2222-4222-8222-222222222222"; @@ -15,6 +17,10 @@ describe("CreateArticleUseCase", () => { let useCase: CreateArticleUseCase; let articleRepository: Pick; let cryptoService: Pick; + let mediaAssetRepository: Pick< + IMediaAssetRepository, + "findByStorageKeys" | "attachToOwner" + >; /** * Returns the entity the use case handed to the repository. @@ -29,9 +35,14 @@ describe("CreateArticleUseCase", () => { cryptoService = { generateRandomHex: vi.fn().mockReturnValue("1a2b3c4d"), }; + mediaAssetRepository = { + findByStorageKeys: vi.fn().mockResolvedValue([]), + attachToOwner: vi.fn().mockResolvedValue(1), + }; useCase = new CreateArticleUseCase( articleRepository as IArticleRepository, cryptoService as CryptoPort, + mediaAssetRepository as IMediaAssetRepository, ); }); @@ -136,4 +147,71 @@ describe("CreateArticleUseCase", () => { expect(created().categories).toEqual([PostCategory.BACKEND]); }); + describe("cover moderation", () => { + const KEY = `articles/covers/${AUTHOR}/${FILE}.png`; + + it("should leave an article with no cover unflagged", async () => { + await useCase.execute({ + authorId: AUTHOR, + title: "My article", + body: "Some prose.", + }); + + expect(created().isSensitive).toBe(false); + expect(mediaAssetRepository.attachToOwner).not.toHaveBeenCalled(); + }); + + it("should carry a borderline cover's verdict onto the article", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue( + [ + { + storageKey: KEY, + status: MediaModerationStatus.SENSITIVE, + } as never, + ], + ); + + await useCase.execute({ + authorId: AUTHOR, + title: "My article", + body: "Some prose.", + coverImageKey: KEY, + }); + + expect(created().isSensitive).toBe(true); + }); + + it("should accept a cover with no asset row, as pre-pipeline articles have", async () => { + // The key is already bound to this author by its prefix, so a + // missing row means "uploaded before moderation existed", not + // "unmoderated content". + await useCase.execute({ + authorId: AUTHOR, + title: "My article", + body: "Some prose.", + coverImageKey: KEY, + }); + + expect(created().isSensitive).toBe(false); + }); + + it("should bind the cover asset to the stored article", async () => { + vi.mocked(articleRepository.create).mockResolvedValue({ + id: "article-1", + } as unknown as Article); + + await useCase.execute({ + authorId: AUTHOR, + title: "My article", + body: "Some prose.", + coverImageKey: KEY, + }); + + expect(mediaAssetRepository.attachToOwner).toHaveBeenCalledWith( + [KEY], + MediaOwnerKind.ARTICLE, + "article-1", + ); + }); + }); }); diff --git a/tests/unit/core/use-cases/article/update-article.usecase.test.ts b/tests/unit/core/use-cases/article/update-article.usecase.test.ts index 75b878fe..61a08037 100644 --- a/tests/unit/core/use-cases/article/update-article.usecase.test.ts +++ b/tests/unit/core/use-cases/article/update-article.usecase.test.ts @@ -1,3 +1,4 @@ +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { UpdateArticleUseCase } from "@core/use-cases/article/update-article"; import type { IArticleRepository } from "@core/ports/repositories/article.repository"; @@ -9,6 +10,7 @@ import { NotFoundError, UnauthorizedActionError, } from "@core/errors"; +import { MediaOwnerKind } from "@core/domain/enums"; import { buildArticle } from "../../../helpers/mock-factories"; const AUTHOR = "11111111-1111-4111-8111-111111111111"; @@ -18,6 +20,10 @@ describe("UpdateArticleUseCase", () => { let useCase: UpdateArticleUseCase; let articleRepository: Pick; let cacheService: Pick; + let mediaAssetRepository: Pick< + IMediaAssetRepository, + "findByStorageKeys" | "attachToOwner" | "detachFromOwner" + >; beforeEach(() => { articleRepository = { @@ -27,10 +33,36 @@ describe("UpdateArticleUseCase", () => { cacheService = { deleteByPattern: vi.fn().mockResolvedValue(undefined), }; + mediaAssetRepository = { + findByStorageKeys: vi.fn().mockResolvedValue([]), + attachToOwner: vi.fn().mockResolvedValue(1), + detachFromOwner: vi.fn().mockResolvedValue(undefined), + }; useCase = new UpdateArticleUseCase( articleRepository as IArticleRepository, cacheService as CachePort, + mediaAssetRepository as IMediaAssetRepository, + ); + }); + + it("should release the cover it supersedes before attaching the new one", async () => { + // "Attached" has to keep meaning "in use": a purge job reading it any + // other way would leave every replaced cover in storage forever. + vi.mocked(articleRepository.findById).mockResolvedValue( + buildArticle({ id: "article-1", author: { id: AUTHOR } }), + ); + + await useCase.execute({ + articleId: "article-1", + userId: AUTHOR, + coverImageKey: `articles/covers/${AUTHOR}/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.png`, + }); + + expect(mediaAssetRepository.detachFromOwner).toHaveBeenCalledWith( + MediaOwnerKind.ARTICLE, + "article-1", ); + expect(mediaAssetRepository.attachToOwner).toHaveBeenCalled(); }); it("should throw NotFoundError when the article does not exist", async () => { diff --git a/tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts b/tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts index ec0f6fe7..c69a5ce7 100644 --- a/tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts +++ b/tests/unit/core/use-cases/article/upload-article-cover.usecase.test.ts @@ -3,9 +3,13 @@ import { UploadArticleCoverUseCase, detectImageType, } from "@core/use-cases/article/upload-article-cover"; +import { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; import type { StoragePort } from "@core/ports/services/storage.port"; import { InvalidFileTypeError, PayloadTooLargeError } from "@core/errors"; +import { fakeModeration, mp4 } from "../../../helpers/media-fixtures"; const USER = "11111111-1111-4111-8111-111111111111"; const UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; @@ -104,9 +108,21 @@ describe("UploadArticleCoverUseCase", () => { }; cryptoService = { generateUuid: vi.fn().mockReturnValue(UUID) }; + const mediaAssetRepository: Pick = { + create: vi.fn().mockImplementation((asset) => asset), + }; + + // The real shared upload path rather than a stub: what this suite is + // checking is that a cover goes through the same byte sniffing and + // moderation as everything else. useCase = new UploadArticleCoverUseCase( - storageService as StoragePort, - cryptoService as CryptoPort, + new UploadModeratedMediaUseCase( + storageService as StoragePort, + fakeModeration(), + mediaAssetRepository as IMediaAssetRepository, + cryptoService as CryptoPort, + { error: vi.fn(), warn: vi.fn() } as unknown as LoggerPort, + ), ); }); @@ -181,6 +197,14 @@ describe("UploadArticleCoverUseCase", () => { ).rejects.toThrow(PayloadTooLargeError); }); + it("should reject a video, which a cover can never be", async () => { + await expect( + useCase.execute({ userId: USER, fileBuffer: mp4() }), + ).rejects.toThrow(InvalidFileTypeError); + + expect(storageService.upload).not.toHaveBeenCalled(); + }); + it("should produce a key that the article body validator accepts", async () => { const key = await useCase.execute({ userId: USER, fileBuffer: PNG }); diff --git a/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts index 346914be..d491d061 100644 --- a/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts @@ -18,6 +18,9 @@ import type { Comment } from "@core/domain/entities/comment.entity"; import { ArticleStatus } from "@core/domain/enums/article-status.enum"; import { NotificationType } from "@core/domain/enums/notification-type.enum"; import { buildArticle, buildComment } from "../../../helpers/mock-factories"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; + +const CDN_URL = "https://cdn.example.com"; const AUTHOR = "article-author-1"; const COMMENTER = "commenter-1"; @@ -32,6 +35,10 @@ describe("CreateCommentUseCase (article target)", () => { let useCase: CreateCommentUseCase; let transactionSvc: Pick; let realtimeSvc: Pick; + let mediaAssetRepo: Pick< + IMediaAssetRepository, + "findByStorageKeys" | "attachToOwner" + >; let txArticleRepo: Pick; let txPostRepo: Pick< IPostRepository, @@ -67,6 +74,10 @@ describe("CreateCommentUseCase (article target)", () => { }; txNotificationRepo = { create: vi.fn() }; realtimeSvc = { emitToUser: vi.fn() }; + mediaAssetRepo = { + findByStorageKeys: vi.fn().mockResolvedValue([]), + attachToOwner: vi.fn().mockResolvedValue(1), + }; transactionSvc = { runInTransaction: vi.fn().mockImplementation(async (work) => work({ @@ -75,6 +86,8 @@ describe("CreateCommentUseCase (article target)", () => { commentRepository: txCommentRepo as ICommentRepository, notificationRepository: txNotificationRepo as INotificationRepository, + mediaAssetRepository: + mediaAssetRepo as IMediaAssetRepository, } as TransactionContext), ), }; @@ -82,6 +95,8 @@ describe("CreateCommentUseCase (article target)", () => { useCase = new CreateCommentUseCase( transactionSvc as TransactionPort, realtimeSvc as RealtimePort, + mediaAssetRepo as IMediaAssetRepository, + CDN_URL, ); }); diff --git a/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts index 625e69b7..3984c85f 100644 --- a/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts @@ -12,6 +12,9 @@ import type { INotificationRepository } from "@core/ports/repositories/notificat import type { Comment } from "@core/domain/entities/comment.entity"; import type { Post } from "@core/domain/entities/post.entity"; import { buildComment } from "../../../helpers/mock-factories"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; + +const CDN_URL = "https://cdn.example.com"; const buildPost = (authorId = "author-1"): Post => ({ @@ -23,6 +26,10 @@ describe("CreateCommentUseCase", () => { let useCase: CreateCommentUseCase; let transactionSvc: Pick; let realtimeSvc: Pick; + let mediaAssetRepo: Pick< + IMediaAssetRepository, + "findByStorageKeys" | "attachToOwner" + >; let txPostRepo: Pick< IPostRepository, "findById" | "incrementCommentsCount" @@ -39,6 +46,7 @@ describe("CreateCommentUseCase", () => { commentRepository: txCommentRepo as ICommentRepository, notificationRepository: txNotificationRepo as INotificationRepository, + mediaAssetRepository: mediaAssetRepo as IMediaAssetRepository, }) as TransactionContext; beforeEach(() => { @@ -53,6 +61,10 @@ describe("CreateCommentUseCase", () => { }; txNotificationRepo = { create: vi.fn() }; realtimeSvc = { emitToUser: vi.fn() }; + mediaAssetRepo = { + findByStorageKeys: vi.fn().mockResolvedValue([]), + attachToOwner: vi.fn().mockResolvedValue(1), + }; transactionSvc = { runInTransaction: vi.fn() }; vi.mocked(transactionSvc.runInTransaction).mockImplementation( @@ -62,6 +74,8 @@ describe("CreateCommentUseCase", () => { useCase = new CreateCommentUseCase( transactionSvc as TransactionPort, realtimeSvc as RealtimePort, + mediaAssetRepo as IMediaAssetRepository, + CDN_URL, ); }); diff --git a/tests/unit/core/use-cases/media/moderate-pending-media.usecase.test.ts b/tests/unit/core/use-cases/media/moderate-pending-media.usecase.test.ts new file mode 100644 index 00000000..2bb82d0c --- /dev/null +++ b/tests/unit/core/use-cases/media/moderate-pending-media.usecase.test.ts @@ -0,0 +1,375 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ModeratePendingMediaUseCase } from "@core/use-cases/media/moderate-pending-media"; +import { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import { + MediaChannel, + MediaKind, + MediaModerationCategory, + MediaModerationStatus, + MediaOwnerKind, + NotificationType, +} from "@core/domain/enums"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { MediaModerationPort } from "@core/ports/services/media-moderation.port"; +import type { StoragePort } from "@core/ports/services/storage.port"; + +const CDN = "https://cdn.example.com"; +const UPLOADER = "user-1"; +const POST_ID = "post-1"; +const KEY = "posts/user-1/clip.mp4"; + +/** + * Builds a claimed video asset attached to a post. + */ +function videoAsset(overrides: Record = {}): MediaAsset { + return MediaAsset.with({ + id: "asset-1", + storageKey: KEY, + kind: MediaKind.VIDEO, + mimeType: "video/mp4", + byteSize: 1000, + uploaderId: UPLOADER, + channel: MediaChannel.POST_MEDIA, + ownerId: POST_ID, + ownerKind: MediaOwnerKind.POST, + status: MediaModerationStatus.SCANNING, + categories: [], + attempts: 0, + ...overrides, + }); +} + +describe("ModeratePendingMediaUseCase", () => { + let useCase: ModeratePendingMediaUseCase; + let mediaAssetRepository: Pick< + IMediaAssetRepository, + | "claimPending" + | "recordOutcome" + | "recordFailedAttempt" + | "findByOwner" + | "findByStorageKeys" + >; + let moderation: MediaModerationPort; + let storageService: Pick; + let postRepository: Pick; + let commentRepository: Pick; + let notificationRepository: Pick; + let logger: Pick; + + beforeEach(() => { + mediaAssetRepository = { + claimPending: vi.fn().mockResolvedValue([videoAsset()]), + recordOutcome: vi.fn().mockResolvedValue(undefined), + recordFailedAttempt: vi.fn().mockResolvedValue(1), + // The worker re-reads the asset after scanning: the claim-time + // snapshot predates the post that claims it. + findByStorageKeys: vi.fn().mockResolvedValue([videoAsset()]), + findByOwner: vi + .fn() + .mockResolvedValue([ + videoAsset({ status: MediaModerationStatus.APPROVED }), + ]), + }; + moderation = { + moderateImage: vi.fn(), + moderateVideo: vi.fn().mockResolvedValue({ + verdict: MediaModerationStatus.APPROVED, + categories: [], + scores: {}, + provider: "fake", + }), + }; + storageService = { delete: vi.fn().mockResolvedValue(undefined) }; + postRepository = { + updateMediaState: vi.fn().mockResolvedValue(undefined), + }; + commentRepository = { + updateMediaState: vi.fn().mockResolvedValue(undefined), + }; + notificationRepository = { + create: vi.fn().mockResolvedValue(undefined), + }; + logger = { error: vi.fn(), warn: vi.fn() }; + + useCase = new ModeratePendingMediaUseCase( + mediaAssetRepository as IMediaAssetRepository, + moderation, + storageService as StoragePort, + postRepository as IPostRepository, + commentRepository as ICommentRepository, + notificationRepository as INotificationRepository, + { + batchSize: 10, + maxAttempts: 3, + leaseSeconds: 600, + r2PublicUrl: CDN, + }, + logger as LoggerPort, + ); + }); + + it("should hand the provider the CDN URL of the claimed asset", async () => { + await useCase.execute(); + + expect(moderation.moderateVideo).toHaveBeenCalledWith(`${CDN}/${KEY}`); + }); + + it("should report nothing to do when the queue is empty", async () => { + vi.mocked(mediaAssetRepository.claimPending).mockResolvedValue([]); + + await expect(useCase.execute()).resolves.toEqual({ + scanned: 0, + approved: 0, + sensitive: 0, + rejected: 0, + failed: 0, + }); + }); + + it("should record a clean verdict and release the post's media", async () => { + const result = await useCase.execute(); + + expect(mediaAssetRepository.recordOutcome).toHaveBeenCalledWith( + "asset-1", + expect.objectContaining({ + status: MediaModerationStatus.APPROVED, + }), + ); + expect(postRepository.updateMediaState).toHaveBeenCalledWith(POST_ID, { + mediaUrls: [`${CDN}/${KEY}`], + isSensitive: false, + mediaStatus: MediaModerationStatus.APPROVED, + }); + expect(result.approved).toBe(1); + expect(storageService.delete).not.toHaveBeenCalled(); + }); + + describe("rejection", () => { + beforeEach(() => { + vi.mocked(moderation.moderateVideo).mockResolvedValue({ + verdict: MediaModerationStatus.REJECTED, + categories: [MediaModerationCategory.GORE], + scores: { "gore.prob": 0.95 }, + provider: "fake", + }); + vi.mocked(mediaAssetRepository.findByOwner).mockResolvedValue([ + videoAsset({ status: MediaModerationStatus.REJECTED }), + ]); + }); + + it("should delete the object and strip it from the post", async () => { + const result = await useCase.execute(); + + expect(storageService.delete).toHaveBeenCalledWith(KEY); + expect(postRepository.updateMediaState).toHaveBeenCalledWith( + POST_ID, + expect.objectContaining({ mediaUrls: [] }), + ); + expect(result.rejected).toBe(1); + }); + + it("should tell the uploader their media was removed", async () => { + await useCase.execute(); + + const [notification] = vi.mocked(notificationRepository.create).mock + .calls[0]; + + expect(notification.recipientId).toBe(UPLOADER); + expect(notification.type).toBe(NotificationType.MEDIA_REJECTED); + }); + + it("should keep going when the object cannot be deleted", async () => { + // The verdict is already recorded and the read path already + // withholds the file; a missed delete costs storage, not safety. + vi.mocked(storageService.delete).mockRejectedValue( + new Error("R2 down"), + ); + + await expect(useCase.execute()).resolves.toMatchObject({ + rejected: 1, + failed: 0, + }); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + it("should mark the owner sensitive when the verdict is borderline", async () => { + vi.mocked(moderation.moderateVideo).mockResolvedValue({ + verdict: MediaModerationStatus.SENSITIVE, + categories: [MediaModerationCategory.SUGGESTIVE], + scores: {}, + provider: "fake", + }); + vi.mocked(mediaAssetRepository.findByOwner).mockResolvedValue([ + videoAsset({ status: MediaModerationStatus.SENSITIVE }), + ]); + + const result = await useCase.execute(); + + expect(postRepository.updateMediaState).toHaveBeenCalledWith( + POST_ID, + expect.objectContaining({ + isSensitive: true, + mediaUrls: [`${CDN}/${KEY}`], + mediaStatus: MediaModerationStatus.APPROVED, + }), + ); + expect(result.sensitive).toBe(1); + }); + + it("should keep the owner pending while a sibling is still unscanned", async () => { + vi.mocked(mediaAssetRepository.findByOwner).mockResolvedValue([ + videoAsset({ status: MediaModerationStatus.APPROVED }), + videoAsset({ + id: "asset-2", + storageKey: "posts/user-1/second.mp4", + status: MediaModerationStatus.PENDING, + }), + ]); + + await useCase.execute(); + + expect(postRepository.updateMediaState).toHaveBeenCalledWith( + POST_ID, + expect.objectContaining({ + mediaStatus: MediaModerationStatus.PENDING, + }), + ); + }); + + it("should write a comment's verdict back to the comment repository", async () => { + const onComment = videoAsset({ + ownerKind: MediaOwnerKind.COMMENT, + ownerId: "comment-1", + }); + + vi.mocked(mediaAssetRepository.claimPending).mockResolvedValue([ + onComment, + ]); + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + onComment, + ]); + + await useCase.execute(); + + expect(commentRepository.updateMediaState).toHaveBeenCalledWith( + "comment-1", + expect.anything(), + ); + expect(postRepository.updateMediaState).not.toHaveBeenCalled(); + }); + + it("should leave an unattached asset alone", async () => { + vi.mocked(mediaAssetRepository.claimPending).mockResolvedValue([ + videoAsset({ ownerId: null, ownerKind: null }), + ]); + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + videoAsset({ ownerId: null, ownerKind: null }), + ]); + + await useCase.execute(); + + expect(postRepository.updateMediaState).not.toHaveBeenCalled(); + expect(commentRepository.updateMediaState).not.toHaveBeenCalled(); + }); + + it("should write to the owner that claimed the asset after it was claimed", async () => { + // The common ordering: the worker picks up an upload before the post + // using it is submitted. Trusting the claim-time snapshot would leave + // that post withholding its media forever, since the asset now has a + // verdict and is never claimed again. + vi.mocked(mediaAssetRepository.claimPending).mockResolvedValue([ + videoAsset({ ownerId: null, ownerKind: null }), + ]); + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + videoAsset({ ownerId: POST_ID, ownerKind: MediaOwnerKind.POST }), + ]); + + await useCase.execute(); + + expect(postRepository.updateMediaState).toHaveBeenCalledWith( + POST_ID, + expect.anything(), + ); + }); + + describe("failures", () => { + beforeEach(() => { + vi.mocked(moderation.moderateVideo).mockRejectedValue( + new Error("provider down"), + ); + }); + + it("should release the asset for another attempt", async () => { + const result = await useCase.execute(); + + expect(mediaAssetRepository.recordFailedAttempt).toHaveBeenCalled(); + expect(result.failed).toBe(1); + expect(storageService.delete).not.toHaveBeenCalled(); + }); + + it("should give up and reject once the retry budget is spent", async () => { + // A file that cannot be checked is one nobody has vouched for. + // Leaving it pending forever would hide it just as thoroughly while + // never telling the author to upload it again. + vi.mocked( + mediaAssetRepository.recordFailedAttempt, + ).mockResolvedValue(3); + + await useCase.execute(); + + expect(mediaAssetRepository.recordOutcome).toHaveBeenCalledWith( + "asset-1", + expect.objectContaining({ + status: MediaModerationStatus.REJECTED, + }), + ); + expect(storageService.delete).toHaveBeenCalledWith(KEY); + expect(notificationRepository.create).toHaveBeenCalled(); + }); + + it("should not let the failure handler strand the rest of the batch", async () => { + // handleFailure writes to the database too. If its own failure + // escaped, every asset still claimed in this batch would be left + // at SCANNING, which only the lease recovers from. + vi.mocked(mediaAssetRepository.claimPending).mockResolvedValue([ + videoAsset({ id: "asset-1" }), + videoAsset({ id: "asset-2" }), + ]); + vi.mocked( + mediaAssetRepository.recordFailedAttempt, + ).mockRejectedValue(new Error("database down")); + + await expect(useCase.execute()).resolves.toMatchObject({ + scanned: 2, + failed: 2, + }); + expect(logger.error).toHaveBeenCalled(); + }); + + it("should not let one bad file block the rest of the batch", async () => { + vi.mocked(mediaAssetRepository.claimPending).mockResolvedValue([ + videoAsset({ id: "asset-1" }), + videoAsset({ id: "asset-2" }), + ]); + vi.mocked(moderation.moderateVideo) + .mockRejectedValueOnce(new Error("provider down")) + .mockResolvedValueOnce({ + verdict: MediaModerationStatus.APPROVED, + categories: [], + scores: {}, + provider: "fake", + }); + + await expect(useCase.execute()).resolves.toMatchObject({ + scanned: 2, + failed: 1, + approved: 1, + }); + }); + }); +}); diff --git a/tests/unit/core/use-cases/media/upload-moderated-media.usecase.test.ts b/tests/unit/core/use-cases/media/upload-moderated-media.usecase.test.ts new file mode 100644 index 00000000..19b59f95 --- /dev/null +++ b/tests/unit/core/use-cases/media/upload-moderated-media.usecase.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; +import { + MediaChannel, + MediaKind, + MediaModerationCategory, + MediaModerationStatus, +} from "@core/domain/enums"; +import { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import type { CryptoPort } from "@core/ports/services/crypto.port"; +import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { MediaModerationPort } from "@core/ports/services/media-moderation.port"; +import type { StoragePort } from "@core/ports/services/storage.port"; +import { + InvalidFileTypeError, + InvalidMediaTypeError, + MediaRejectedError, + ModerationUnavailableError, + PayloadTooLargeError, +} from "@core/errors"; +import { JPEG, PNG, SVG, mp4 } from "../../../helpers/media-fixtures"; + +const USER = "11111111-1111-4111-8111-111111111111"; +const UUID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + +describe("UploadModeratedMediaUseCase", () => { + let useCase: UploadModeratedMediaUseCase; + let storageService: Pick; + let moderation: MediaModerationPort; + let mediaAssetRepository: Pick; + let cryptoService: Pick; + let logger: Pick; + + const postMedia = { + userId: USER, + channel: MediaChannel.POST_MEDIA, + keyPrefix: "posts/" + USER, + maxBytes: 5 * 1024 * 1024, + allowVideo: true, + }; + + beforeEach(() => { + storageService = { + upload: vi + .fn() + .mockImplementation((key: string) => Promise.resolve(key)), + }; + moderation = { + moderateImage: vi.fn().mockResolvedValue({ + verdict: MediaModerationStatus.APPROVED, + categories: [], + scores: {}, + provider: "fake", + }), + moderateVideo: vi.fn(), + }; + mediaAssetRepository = { + // Echoes the entity back the way a create does, id aside. + create: vi.fn().mockImplementation((asset: MediaAsset) => { + return Promise.resolve(asset); + }), + }; + cryptoService = { generateUuid: vi.fn().mockReturnValue(UUID) }; + logger = { error: vi.fn(), warn: vi.fn() }; + + useCase = new UploadModeratedMediaUseCase( + storageService as StoragePort, + moderation, + mediaAssetRepository as IMediaAssetRepository, + cryptoService as CryptoPort, + logger as LoggerPort, + ); + }); + + describe("image path", () => { + it("should scan the image before anything is written to storage", async () => { + vi.mocked(moderation.moderateImage).mockResolvedValue({ + verdict: MediaModerationStatus.REJECTED, + categories: [MediaModerationCategory.SEXUAL_ACTIVITY], + scores: { "nudity.sexual_activity": 0.98 }, + provider: "fake", + }); + + await expect( + useCase.execute({ ...postMedia, fileBuffer: JPEG }), + ).rejects.toThrow(MediaRejectedError); + + // The whole point of scanning first: a refused file never gets a + // URL, not even an unlisted one. + expect(storageService.upload).not.toHaveBeenCalled(); + expect(mediaAssetRepository.create).not.toHaveBeenCalled(); + }); + + it("should store a borderline image and record it as sensitive", async () => { + vi.mocked(moderation.moderateImage).mockResolvedValue({ + verdict: MediaModerationStatus.SENSITIVE, + categories: [MediaModerationCategory.SUGGESTIVE], + scores: { "nudity.suggestive": 0.6 }, + provider: "fake", + }); + + const result = await useCase.execute({ + ...postMedia, + fileBuffer: PNG, + }); + + expect(storageService.upload).toHaveBeenCalledOnce(); + expect(result.status).toBe(MediaModerationStatus.SENSITIVE); + }); + + it("should store a clean image as approved", async () => { + const result = await useCase.execute({ + ...postMedia, + fileBuffer: PNG, + }); + + expect(result).toEqual({ + storageKey: `posts/${USER}/${UUID}.png`, + kind: MediaKind.IMAGE, + status: MediaModerationStatus.APPROVED, + }); + }); + + it("should pass the sniffed mime type on, not a client-supplied one", async () => { + await useCase.execute({ ...postMedia, fileBuffer: JPEG }); + + expect(moderation.moderateImage).toHaveBeenCalledWith( + JPEG, + "image/jpeg", + ); + expect(storageService.upload).toHaveBeenCalledWith( + `posts/${USER}/${UUID}.jpg`, + JPEG, + "image/jpeg", + ); + }); + + it("should fail closed when the provider cannot be reached", async () => { + vi.mocked(moderation.moderateImage).mockRejectedValue( + new Error("ETIMEDOUT"), + ); + + await expect( + useCase.execute({ ...postMedia, fileBuffer: PNG }), + ).rejects.toThrow(ModerationUnavailableError); + + // Waving files through during an outage would make the outage the + // way past the filter. + expect(storageService.upload).not.toHaveBeenCalled(); + }); + }); + + describe("video path", () => { + it("should store a video as pending without calling the provider", async () => { + const result = await useCase.execute({ + ...postMedia, + fileBuffer: mp4(), + }); + + expect(result).toEqual({ + storageKey: `posts/${USER}/${UUID}.mp4`, + kind: MediaKind.VIDEO, + status: MediaModerationStatus.PENDING, + }); + // The worker owns video: sampling one takes far longer than a + // request may be held open. + expect(moderation.moderateImage).not.toHaveBeenCalled(); + }); + + it("should refuse a video on an image-only endpoint", async () => { + await expect( + useCase.execute({ + ...postMedia, + fileBuffer: mp4(), + allowVideo: false, + }), + ).rejects.toThrow(InvalidFileTypeError); + + expect(storageService.upload).not.toHaveBeenCalled(); + }); + }); + + describe("input validation", () => { + it("should reject bytes that are not a supported format", async () => { + await expect( + useCase.execute({ ...postMedia, fileBuffer: SVG }), + ).rejects.toThrow(InvalidMediaTypeError); + }); + + it("should reject a stream the transport truncated", async () => { + // The bytes that were cut off are exactly the ones moderation + // never got to look at. + await expect( + useCase.execute({ + ...postMedia, + fileBuffer: PNG, + truncated: true, + }), + ).rejects.toThrow(PayloadTooLargeError); + }); + + it("should reject a buffer over the size limit", async () => { + const tooBig = Buffer.alloc(5 * 1024 * 1024 + 1); + tooBig[0] = 0xff; + tooBig[1] = 0xd8; + tooBig[2] = 0xff; + + await expect( + useCase.execute({ ...postMedia, fileBuffer: tooBig }), + ).rejects.toThrow(PayloadTooLargeError); + }); + + it("should never derive the file name from the upload", async () => { + const key = ( + await useCase.execute({ ...postMedia, fileBuffer: PNG }) + ).storageKey; + + expect(key).toBe(`posts/${USER}/${UUID}.png`); + expect(cryptoService.generateUuid).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/core/use-cases/post/create-post.usecase.test.ts b/tests/unit/core/use-cases/post/create-post.usecase.test.ts index 09ef5dfc..8555e5b7 100644 --- a/tests/unit/core/use-cases/post/create-post.usecase.test.ts +++ b/tests/unit/core/use-cases/post/create-post.usecase.test.ts @@ -8,6 +8,7 @@ import type { import type { IUserRepository } from "@core/ports/repositories/user.repository"; import type { CachePort } from "@core/ports/services/cache.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; import type { NotifyNewPostUseCase } from "@core/use-cases/notification/notify-new-post"; import type { NotifyQuotedAuthorUseCase } from "@core/use-cases/notification/notify-quoted-author"; import type { LanguageDetectionPort } from "@core/ports/services/language-detection.port"; @@ -15,8 +16,18 @@ import { NotFoundError } from "@core/errors/common/not-found.error"; import { ForbiddenError } from "@core/errors/common/forbidden.error"; import { BadRequestError } from "@core/errors/common/bad-request.error"; import { PostType } from "@core/domain/enums/post-type.enum"; +import { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import { + MediaChannel, + MediaKind, + MediaModerationStatus, + MediaOwnerKind, +} from "@core/domain/enums"; +import { MediaNotOwnedError } from "@core/errors"; import { buildUser, buildPost } from "../../../helpers/mock-factories"; +const CDN_URL = "https://cdn.example.com"; + describe("CreatePostUseCase", () => { let useCase: CreatePostUseCase; // The transactional repository, reached through the mocked transaction. @@ -31,6 +42,10 @@ describe("CreatePostUseCase", () => { let notifyQuotedAuthorUseCase: Pick; let languageDetectionService: LanguageDetectionPort; let logger: Pick; + let mediaAssetRepository: Pick< + IMediaAssetRepository, + "findByStorageKeys" | "attachToOwner" + >; beforeEach(() => { postRepository = { @@ -38,12 +53,17 @@ describe("CreatePostUseCase", () => { findById: vi.fn().mockResolvedValue(buildPost()), incrementQuoteCount: vi.fn().mockResolvedValue(undefined), }; + mediaAssetRepository = { + findByStorageKeys: vi.fn().mockResolvedValue([]), + attachToOwner: vi.fn().mockResolvedValue(1), + }; transactionService = { - runInTransaction: vi - .fn() - .mockImplementation(async (work) => - work({ postRepository } as unknown as TransactionContext), - ), + runInTransaction: vi.fn().mockImplementation(async (work) => + work({ + postRepository, + mediaAssetRepository, + } as unknown as TransactionContext), + ), }; userRepository = { findById: vi.fn(), @@ -68,6 +88,8 @@ describe("CreatePostUseCase", () => { notifyNewPostUseCase as NotifyNewPostUseCase, notifyQuotedAuthorUseCase as NotifyQuotedAuthorUseCase, languageDetectionService, + mediaAssetRepository as IMediaAssetRepository, + CDN_URL, logger as LoggerPort, ); }); @@ -377,6 +399,112 @@ describe("CreatePostUseCase", () => { ).toBe("post-1"); }); }); + describe("media ownership", () => { + const KEY = "posts/user-1/abc.jpg"; + const URL = `${CDN_URL}/${KEY}`; + + const uploaded = ( + overrides: Record = {}, + ): MediaAsset => + MediaAsset.with({ + id: "asset-1", + storageKey: KEY, + kind: MediaKind.IMAGE, + mimeType: "image/jpeg", + byteSize: 100, + uploaderId: "user-1", + channel: MediaChannel.POST_MEDIA, + status: MediaModerationStatus.APPROVED, + categories: [], + attempts: 0, + ...overrides, + }); + + it("should refuse a media URL that no upload produced", async () => { + // Without this the whole pipeline is decorative: a client can skip + // the upload endpoint and put any URL it likes in the body. + await expect( + useCase.execute({ + content: "look at this", + type: PostType.COMMUNITY, + authorId: "user-1", + mediaUrls: ["https://evil.example.com/whatever.jpg"], + }), + ).rejects.toThrow(MediaNotOwnedError); + + expect(postRepository.create).not.toHaveBeenCalled(); + }); + + it("should refuse a key uploaded by someone else", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue( + [uploaded({ uploaderId: "user-2" })], + ); + + await expect( + useCase.execute({ + content: "look at this", + type: PostType.COMMUNITY, + authorId: "user-1", + mediaUrls: [URL], + }), + ).rejects.toThrow(MediaNotOwnedError); + }); + + it("should bind the assets to the post inside the transaction", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue( + [uploaded()], + ); + vi.mocked(postRepository.create).mockResolvedValue( + buildPost({ id: "post-7" }), + ); + + await useCase.execute({ + content: "look at this", + type: PostType.COMMUNITY, + authorId: "user-1", + mediaUrls: [URL], + }); + + expect(mediaAssetRepository.attachToOwner).toHaveBeenCalledWith( + [KEY], + MediaOwnerKind.POST, + "post-7", + ); + }); + + it("should carry a pending video's state onto the post", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue( + [uploaded({ status: MediaModerationStatus.PENDING })], + ); + + await useCase.execute({ + content: "clip", + type: PostType.COMMUNITY, + authorId: "user-1", + mediaUrls: [URL], + }); + + const [stored] = vi.mocked(postRepository.create).mock.calls[0]; + expect(stored.mediaStatus).toBe(MediaModerationStatus.PENDING); + }); + + it("should mark the post sensitive when an asset is borderline", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue( + [uploaded({ status: MediaModerationStatus.SENSITIVE })], + ); + + await useCase.execute({ + content: "borderline", + type: PostType.COMMUNITY, + authorId: "user-1", + mediaUrls: [URL], + }); + + const [stored] = vi.mocked(postRepository.create).mock.calls[0]; + expect(stored.isSensitive).toBe(true); + }); + }); + describe("language detection", () => { it("should label the post with the detected language", async () => { vi.mocked(languageDetectionService.detect).mockResolvedValue("tr"); @@ -424,6 +552,7 @@ describe("CreatePostUseCase", () => { order.push("transaction"); return work({ postRepository, + mediaAssetRepository, } as unknown as TransactionContext); }, ); diff --git a/tests/unit/core/use-cases/post/delete-post.usecase.test.ts b/tests/unit/core/use-cases/post/delete-post.usecase.test.ts index 79f902c2..4b8fe137 100644 --- a/tests/unit/core/use-cases/post/delete-post.usecase.test.ts +++ b/tests/unit/core/use-cases/post/delete-post.usecase.test.ts @@ -41,6 +41,7 @@ describe("DeletePostUseCase", () => { }; logger = { error: vi.fn(), + warn: vi.fn(), }; cacheService = { deleteByPattern: vi.fn().mockResolvedValue(undefined), diff --git a/tests/unit/core/use-cases/post/feed-ranking.test.ts b/tests/unit/core/use-cases/post/feed-ranking.test.ts index aad7d3d9..f1c3a175 100644 --- a/tests/unit/core/use-cases/post/feed-ranking.test.ts +++ b/tests/unit/core/use-cases/post/feed-ranking.test.ts @@ -336,6 +336,47 @@ describe("rankFeed", () => { expect(head.filter((c) => c.lang === "en").length).toBeGreaterThan(0); }); + it("should keep serving when the viewer's language runs out", () => { + // The quota is a ceiling on content the reader cannot read, not a + // floor on content they can. With two Turkish posts and a wall of + // English, the feed serves the two and then keeps going. + const pool = [ + candidate({ id: "tr-1", lang: "tr" }), + candidate({ id: "tr-2", lang: "tr" }), + ...Array.from({ length: 30 }, (_, i) => + candidate({ id: `en-${i}`, lang: "en" }), + ), + ]; + + const ranked = rankFeed(pool, context(), WEIGHTS); + + expect(ranked).toHaveLength(pool.length); + expect( + ranked + .slice(0, 2) + .map((c) => c.id) + .sort(), + ).toEqual(["tr-1", "tr-2"]); + expect(ranked[2].lang).toBe("en"); + }); + + it("should hold foreign content to the quota while native content lasts", () => { + const pool = [ + ...Array.from({ length: 20 }, (_, i) => + candidate({ id: `tr-${i}`, lang: "tr" }), + ), + ...Array.from({ length: 20 }, (_, i) => + candidate({ id: `en-${i}`, lang: "en", likeCount: 500 }), + ), + ]; + + const head = rankFeed(pool, context(), WEIGHTS).slice(0, 10); + + expect(head.filter((c) => c.lang === "en").length).toBeLessThanOrEqual( + Math.ceil(10 * WEIGHTS.foreignLanguageQuota), + ); + }); + it("should return an empty order for an empty pool", () => { expect(rankFeed([], context(), WEIGHTS)).toEqual([]); }); diff --git a/tests/unit/core/use-cases/post/get-posts.usecase.test.ts b/tests/unit/core/use-cases/post/get-posts.usecase.test.ts index 0ec9b63a..94a3dd57 100644 --- a/tests/unit/core/use-cases/post/get-posts.usecase.test.ts +++ b/tests/unit/core/use-cases/post/get-posts.usecase.test.ts @@ -801,6 +801,168 @@ describe("GetPostsUseCase", () => { }); }); + describe("when the ranked window runs out mid-page", () => { + it("should fill the page from the tail rather than serving a short one", async () => { + // The production bug: a Turkish reader of the community feed got + // the two matching posts that existed and then "no more posts", + // while hundreds of older ones sat behind the ranked window. + seedPool(2); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: Array.from({ length: 8 }, (_, i) => + buildPost({ id: `tail-${i}` }), + ), + total: 500, + }); + + const result = await useCase.execute({ + limit: 10, + currentUserId: "user-1", + }); + + expect(result.posts).toHaveLength(10); + expect(result.posts.slice(0, 2).map((p) => p.id)).toEqual([ + "p1", + "p2", + ]); + expect(result.posts[2].id).toBe("tail-0"); + }); + + it("should keep handing out a cursor so the reader can go on", async () => { + seedPool(2); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: Array.from({ length: 8 }, (_, i) => + buildPost({ id: `tail-${i}` }), + ), + total: 500, + }); + + const result = await useCase.execute({ limit: 10 }); + + expect(result.nextCursor).not.toBeNull(); + // Two ranked plus eight from the tail, so the next page starts at + // ten in the snapshot's coordinates. + expect(decodeFeedCursor(result.nextCursor!)?.offset).toBe(10); + }); + + it("should not repeat the ranked posts in the top-up", async () => { + seedPool(2); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: [buildPost({ id: "tail-0" })], + total: 500, + }); + + await useCase.execute({ limit: 10 }); + + expect( + vi.mocked(postRepository.findAll).mock.calls[0][0].excludeIds, + ).toEqual(["p1", "p2"]); + }); + + it("should continue from the right place on the next page", async () => { + seedPool(2); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: Array.from({ length: 4 }, (_, i) => + buildPost({ id: `tail-${i}` }), + ), + total: 500, + }); + + const first = await useCase.execute({ limit: 6 }); + await useCase.execute({ limit: 6, cursor: first.nextCursor! }); + + // Four tail rows were already served, so the next read skips them + // rather than starting the tail over. + expect( + vi.mocked(postRepository.findAll).mock.calls[1][0].skip, + ).toBe(4); + }); + + it("should stop only when the tail is genuinely empty", async () => { + seedPool(2); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: [], + total: 2, + }); + + const result = await useCase.execute({ limit: 10 }); + + expect(result.posts).toHaveLength(2); + expect(result.nextCursor).toBeNull(); + }); + + it("should record the topped-up posts as seen as well", async () => { + seedPool(2); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: [buildPost({ id: "tail-0" })], + total: 500, + }); + + await useCase.execute({ limit: 5, currentUserId: "user-1" }); + + expect(seenPostsService.markSeen).toHaveBeenCalledWith("user-1", [ + "p1", + "p2", + "tail-0", + ]); + }); + + it("should not reach for the tail when the ranked window filled the page", async () => { + seedPool(20); + hydrateRequestedIds(); + + await useCase.execute({ limit: 10 }); + + expect(postRepository.findAll).not.toHaveBeenCalled(); + }); + }); + + describe("reaching the end of the tail", () => { + it("should stop handing out a cursor once the tail runs short", async () => { + // A short read means the database had fewer rows left, which is + // the only honest end-of-feed signal available. + seedPool(4); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: [buildPost({ id: "t1" })], + total: 500, + }); + + const first = await useCase.execute({ limit: 4 }); + const second = await useCase.execute({ + limit: 4, + cursor: first.nextCursor!, + }); + + expect(second.posts.map((p) => p.id)).toEqual(["t1"]); + expect(second.nextCursor).toBeNull(); + }); + + it("should keep going while the tail keeps filling the page", async () => { + seedPool(4); + hydrateRequestedIds(); + vi.mocked(postRepository.findAll).mockResolvedValue({ + posts: Array.from({ length: 4 }, (_, i) => + buildPost({ id: `t${i}` }), + ), + total: 500, + }); + + const first = await useCase.execute({ limit: 4 }); + const second = await useCase.execute({ + limit: 4, + cursor: first.nextCursor!, + }); + + expect(second.nextCursor).not.toBeNull(); + }); + }); + describe("feeds that are not ranked", () => { it("should serve release notes chronologically", async () => { const posts = [buildPost({ id: "p1" }), buildPost({ id: "p2" })]; diff --git a/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts b/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts index bc01689c..01d6b15b 100644 --- a/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts +++ b/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts @@ -1,77 +1,77 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { UploadPostMediaUseCase } from "@core/use-cases/post/upload-post-media"; -import type { StoragePort } from "@core/ports/services/storage.port"; -import { InvalidMediaTypeError } from "@core/errors"; +import type { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; +import { + MediaChannel, + MediaKind, + MediaModerationStatus, +} from "@core/domain/enums"; + +const USER = "user-1"; describe("UploadPostMediaUseCase", () => { let useCase: UploadPostMediaUseCase; - let storageService: Pick; + let uploadModeratedMediaUseCase: Pick< + UploadModeratedMediaUseCase, + "execute" + >; beforeEach(() => { - storageService = { - upload: vi - .fn() - .mockResolvedValue( - "https://cdn.example.com/posts/user-1/file.jpg", - ), - }; - useCase = new UploadPostMediaUseCase(storageService as StoragePort); - }); - - it("should throw InvalidMediaTypeError for non-image/video MIME type", async () => { - await expect( - useCase.execute({ - userId: "user-1", - fileBuffer: Buffer.from("data"), - mimeType: "application/pdf", - originalFileName: "document.pdf", + uploadModeratedMediaUseCase = { + execute: vi.fn().mockResolvedValue({ + storageKey: "posts/user-1/generated.jpg", + kind: MediaKind.IMAGE, + status: MediaModerationStatus.APPROVED, }), - ).rejects.toThrow(InvalidMediaTypeError); - - expect(storageService.upload).not.toHaveBeenCalled(); - }); + }; - it("should upload image and return URL", async () => { - vi.mocked(storageService.upload).mockResolvedValue( - "https://cdn.example.com/posts/user-1/photo.png", + useCase = new UploadPostMediaUseCase( + uploadModeratedMediaUseCase as UploadModeratedMediaUseCase, ); + }); + it("should return the storage key the shared upload path produced", async () => { const result = await useCase.execute({ - userId: "user-1", + userId: USER, fileBuffer: Buffer.from("img"), - mimeType: "image/png", - originalFileName: "photo.png", }); - expect(result).toBe("https://cdn.example.com/posts/user-1/photo.png"); - expect(storageService.upload).toHaveBeenCalledOnce(); + expect(result).toBe("posts/user-1/generated.jpg"); }); - it("should upload video and return URL", async () => { - vi.mocked(storageService.upload).mockResolvedValue( - "https://cdn.example.com/posts/user-1/clip.mp4", - ); + it("should upload through the post media channel, under the uploader's prefix", async () => { + const fileBuffer = Buffer.from("img"); - const result = await useCase.execute({ - userId: "user-1", - fileBuffer: Buffer.from("vid"), - mimeType: "video/mp4", - originalFileName: "clip.mp4", + await useCase.execute({ userId: USER, fileBuffer }); + + expect(uploadModeratedMediaUseCase.execute).toHaveBeenCalledWith({ + userId: USER, + fileBuffer, + channel: MediaChannel.POST_MEDIA, + keyPrefix: "posts/user-1", + truncated: undefined, + maxBytes: 5 * 1024 * 1024, + allowVideo: true, }); + }); - expect(result).toBe("https://cdn.example.com/posts/user-1/clip.mp4"); - expect(storageService.upload).toHaveBeenCalledOnce(); + it("should allow video, as the only endpoint that does", async () => { + await useCase.execute({ userId: USER, fileBuffer: Buffer.from("vid") }); + + expect(uploadModeratedMediaUseCase.execute).toHaveBeenCalledWith( + expect.objectContaining({ allowVideo: true }), + ); }); - it("should generate filename with posts/{userId}/ prefix", async () => { + it("should pass the truncation flag on so a cut-off file is refused", async () => { await useCase.execute({ - userId: "user-42", + userId: USER, fileBuffer: Buffer.from("img"), - mimeType: "image/jpeg", - originalFileName: "avatar.jpg", + truncated: true, }); - const [passedFileName] = vi.mocked(storageService.upload).mock.calls[0]; - expect(passedFileName).toMatch(/^posts\/user-42\/.+\.jpg$/); + expect(uploadModeratedMediaUseCase.execute).toHaveBeenCalledWith( + expect.objectContaining({ truncated: true }), + ); }); }); diff --git a/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts b/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts index 21de2046..8bc479b2 100644 --- a/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts +++ b/tests/unit/core/use-cases/profile/update-avatar.usecase.test.ts @@ -3,23 +3,33 @@ import { UpdateAvatarUseCase } from "@core/use-cases/profile/update-avatar"; import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; -import { InvalidFileTypeError } from "@core/errors"; +import type { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; +import { + MediaChannel, + MediaKind, + MediaModerationStatus, +} from "@core/domain/enums"; +import { MediaRejectedError } from "@core/errors"; import { DEFAULT_AVATAR_KEY } from "@core/domain/constants/default-media.constants"; +const NEW_KEY = "avatars/user-1/new.jpg"; + describe("UpdateAvatarUseCase", () => { let useCase: UpdateAvatarUseCase; let profileRepository: Pick< IProfileRepository, "findAvatarByUserId" | "updateAvatar" >; - let storageService: Pick; + let storageService: Pick; let logger: Pick; + let uploadModeratedMediaUseCase: Pick< + UploadModeratedMediaUseCase, + "execute" + >; const baseInput = { userId: "user-1", fileBuffer: Buffer.from("image-data"), - mimeType: "image/jpeg", - originalFileName: "photo.jpg", }; beforeEach(() => { @@ -28,50 +38,61 @@ describe("UpdateAvatarUseCase", () => { updateAvatar: vi.fn().mockResolvedValue(undefined), }; storageService = { - upload: vi.fn().mockResolvedValue("avatars/user-1-123.jpg"), delete: vi.fn().mockResolvedValue(undefined), }; - logger = { - error: vi.fn(), + logger = { error: vi.fn() }; + uploadModeratedMediaUseCase = { + execute: vi.fn().mockResolvedValue({ + storageKey: NEW_KEY, + kind: MediaKind.IMAGE, + status: MediaModerationStatus.APPROVED, + }), }; + useCase = new UpdateAvatarUseCase( profileRepository as IProfileRepository, + uploadModeratedMediaUseCase as UploadModeratedMediaUseCase, storageService as StoragePort, logger as LoggerPort, ); }); - it("should throw InvalidFileTypeError when mimeType does not start with 'image/'", async () => { - await expect( - useCase.execute({ ...baseInput, mimeType: "application/pdf" }), - ).rejects.toThrow(InvalidFileTypeError); + it("should upload through the avatar channel and refuse video", async () => { + await useCase.execute(baseInput); - expect(storageService.upload).not.toHaveBeenCalled(); - expect(profileRepository.updateAvatar).not.toHaveBeenCalled(); + expect(uploadModeratedMediaUseCase.execute).toHaveBeenCalledWith({ + userId: "user-1", + fileBuffer: baseInput.fileBuffer, + channel: MediaChannel.AVATAR, + keyPrefix: "avatars/user-1", + truncated: undefined, + maxBytes: 5 * 1024 * 1024, + allowVideo: false, + }); }); - it("should upload the file and update the profile with the new URL", async () => { - vi.mocked(storageService.upload).mockResolvedValue( - "avatars/user-1-new.jpg", - ); - + it("should update the profile with the stored key and return it", async () => { const result = await useCase.execute(baseInput); - expect(storageService.upload).toHaveBeenCalledOnce(); expect(profileRepository.updateAvatar).toHaveBeenCalledWith( "user-1", - "avatars/user-1-new.jpg", + NEW_KEY, ); - expect(result).toBe("avatars/user-1-new.jpg"); + expect(result).toBe(NEW_KEY); }); - it("should generate the filename with userId and extension from originalFileName", async () => { - await useCase.execute(baseInput); + it("should not touch the profile when moderation refuses the image", async () => { + vi.mocked(uploadModeratedMediaUseCase.execute).mockRejectedValue( + new MediaRejectedError(), + ); + + await expect(useCase.execute(baseInput)).rejects.toThrow( + MediaRejectedError, + ); - const uploadCall = vi.mocked(storageService.upload).mock.calls[0]; - expect(uploadCall[0]).toMatch(/^avatars\/user-1-\d+\.jpg$/); - expect(uploadCall[1]).toBe(baseInput.fileBuffer); - expect(uploadCall[2]).toBe("image/jpeg"); + // An avatar travels into every feed and notification the user appears + // in, so a refused one must leave the old one in place. + expect(profileRepository.updateAvatar).not.toHaveBeenCalled(); }); it("should not call storageService.delete when there is no old avatar", async () => { @@ -112,21 +133,7 @@ describe("UpdateAvatarUseCase", () => { new Error("Storage unavailable"), ); - await expect(useCase.execute(baseInput)).resolves.toBeDefined(); + await expect(useCase.execute(baseInput)).resolves.toBe(NEW_KEY); expect(logger.error).toHaveBeenCalledOnce(); }); - - it("should still return the uploaded path even when old avatar deletion fails", async () => { - vi.mocked(profileRepository.findAvatarByUserId).mockResolvedValue( - "avatars/user-1-old.jpg", - ); - vi.mocked(storageService.upload).mockResolvedValue( - "avatars/user-1-new.jpg", - ); - vi.mocked(storageService.delete).mockRejectedValue(new Error("fail")); - - const result = await useCase.execute(baseInput); - - expect(result).toBe("avatars/user-1-new.jpg"); - }); }); diff --git a/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts b/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts index 34d4a29b..551b2311 100644 --- a/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts +++ b/tests/unit/core/use-cases/profile/update-banner.usecase.test.ts @@ -3,23 +3,33 @@ import { UpdateBannerUseCase } from "@core/use-cases/profile/update-banner"; import type { IProfileRepository } from "@core/ports/repositories/profile.repository"; import type { StoragePort } from "@core/ports/services/storage.port"; import type { LoggerPort } from "@core/ports/services/logger.port"; -import { InvalidFileTypeError } from "@core/errors"; +import type { UploadModeratedMediaUseCase } from "@core/use-cases/media/upload-moderated-media"; +import { + MediaChannel, + MediaKind, + MediaModerationStatus, +} from "@core/domain/enums"; +import { MediaRejectedError } from "@core/errors"; import { DEFAULT_BANNER_KEY } from "@core/domain/constants/default-media.constants"; +const NEW_KEY = "banners/user-1/new.jpg"; + describe("UpdateBannerUseCase", () => { let useCase: UpdateBannerUseCase; let profileRepository: Pick< IProfileRepository, "findBannerByUserId" | "updateBanner" >; - let storageService: Pick; + let storageService: Pick; let logger: Pick; + let uploadModeratedMediaUseCase: Pick< + UploadModeratedMediaUseCase, + "execute" + >; const baseInput = { userId: "user-1", - fileBuffer: Buffer.from("banner-data"), - mimeType: "image/jpeg", - originalFileName: "banner.jpg", + fileBuffer: Buffer.from("image-data"), }; beforeEach(() => { @@ -28,50 +38,59 @@ describe("UpdateBannerUseCase", () => { updateBanner: vi.fn().mockResolvedValue(undefined), }; storageService = { - upload: vi.fn().mockResolvedValue("banners/user-1-123.jpg"), delete: vi.fn().mockResolvedValue(undefined), }; - logger = { - error: vi.fn(), + logger = { error: vi.fn() }; + uploadModeratedMediaUseCase = { + execute: vi.fn().mockResolvedValue({ + storageKey: NEW_KEY, + kind: MediaKind.IMAGE, + status: MediaModerationStatus.APPROVED, + }), }; + useCase = new UpdateBannerUseCase( profileRepository as IProfileRepository, + uploadModeratedMediaUseCase as UploadModeratedMediaUseCase, storageService as StoragePort, logger as LoggerPort, ); }); - it("should throw InvalidFileTypeError when mimeType does not start with 'image/'", async () => { - await expect( - useCase.execute({ ...baseInput, mimeType: "application/pdf" }), - ).rejects.toThrow(InvalidFileTypeError); + it("should upload through the banner channel and refuse video", async () => { + await useCase.execute(baseInput); - expect(storageService.upload).not.toHaveBeenCalled(); - expect(profileRepository.updateBanner).not.toHaveBeenCalled(); + expect(uploadModeratedMediaUseCase.execute).toHaveBeenCalledWith({ + userId: "user-1", + fileBuffer: baseInput.fileBuffer, + channel: MediaChannel.BANNER, + keyPrefix: "banners/user-1", + truncated: undefined, + maxBytes: 5 * 1024 * 1024, + allowVideo: false, + }); }); - it("should upload the file and update the profile with the new URL", async () => { - vi.mocked(storageService.upload).mockResolvedValue( - "banners/user-1-new.jpg", - ); - + it("should update the profile with the stored key and return it", async () => { const result = await useCase.execute(baseInput); - expect(storageService.upload).toHaveBeenCalledOnce(); expect(profileRepository.updateBanner).toHaveBeenCalledWith( "user-1", - "banners/user-1-new.jpg", + NEW_KEY, ); - expect(result).toBe("banners/user-1-new.jpg"); + expect(result).toBe(NEW_KEY); }); - it("should generate the filename with userId and extension from originalFileName", async () => { - await useCase.execute(baseInput); + it("should not touch the profile when moderation refuses the image", async () => { + vi.mocked(uploadModeratedMediaUseCase.execute).mockRejectedValue( + new MediaRejectedError(), + ); + + await expect(useCase.execute(baseInput)).rejects.toThrow( + MediaRejectedError, + ); - const uploadCall = vi.mocked(storageService.upload).mock.calls[0]; - expect(uploadCall[0]).toMatch(/^banners\/user-1-\d+\.jpg$/); - expect(uploadCall[1]).toBe(baseInput.fileBuffer); - expect(uploadCall[2]).toBe("image/jpeg"); + expect(profileRepository.updateBanner).not.toHaveBeenCalled(); }); it("should not call storageService.delete when there is no old banner", async () => { @@ -82,34 +101,22 @@ describe("UpdateBannerUseCase", () => { expect(storageService.delete).not.toHaveBeenCalled(); }); - it("should not call storageService.delete when old banner is the default", async () => { - vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue( - `https://cdn.example.com/${DEFAULT_BANNER_KEY}`, - ); - - await useCase.execute(baseInput); - - expect(storageService.delete).not.toHaveBeenCalled(); - }); - - it("should not call storageService.delete for the schema default banner key", async () => { - vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue( + it("should not call storageService.delete for the default banner, however it is stored", async () => { + // The default has been written as a bare key, as a CDN URL, and with a + // cache-busting query. None of the three may be deleted. + for (const stored of [ DEFAULT_BANNER_KEY, - ); - - await useCase.execute(baseInput); - - expect(storageService.delete).not.toHaveBeenCalled(); - }); - - it("should not call storageService.delete when the default banner carries a cache-busting query", async () => { - vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue( + `https://cdn.example.com/${DEFAULT_BANNER_KEY}`, `https://cdn.example.com/${DEFAULT_BANNER_KEY}?v=1`, - ); + ]) { + vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue( + stored, + ); - await useCase.execute(baseInput); + await useCase.execute(baseInput); - expect(storageService.delete).not.toHaveBeenCalled(); + expect(storageService.delete).not.toHaveBeenCalled(); + } }); it("should delete the old banner when it exists and is not the default", async () => { @@ -132,21 +139,7 @@ describe("UpdateBannerUseCase", () => { new Error("Storage unavailable"), ); - await expect(useCase.execute(baseInput)).resolves.toBeDefined(); + await expect(useCase.execute(baseInput)).resolves.toBe(NEW_KEY); expect(logger.error).toHaveBeenCalledOnce(); }); - - it("should still return the uploaded path even when old banner deletion fails", async () => { - vi.mocked(profileRepository.findBannerByUserId).mockResolvedValue( - "banners/user-1-old.jpg", - ); - vi.mocked(storageService.upload).mockResolvedValue( - "banners/user-1-new.jpg", - ); - vi.mocked(storageService.delete).mockRejectedValue(new Error("fail")); - - const result = await useCase.execute(baseInput); - - expect(result).toBe("banners/user-1-new.jpg"); - }); }); diff --git a/tests/unit/core/use-cases/shared/detect-media-type.test.ts b/tests/unit/core/use-cases/shared/detect-media-type.test.ts new file mode 100644 index 00000000..b862c0cc --- /dev/null +++ b/tests/unit/core/use-cases/shared/detect-media-type.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { MediaKind } from "@core/domain/enums"; +import { detectMediaType } from "@core/use-cases/shared/media/detect-media-type"; +import { + GIF, + HTML, + JPEG, + PNG, + SVG, + WEBM, + avif, + isoMedia, + mov, + mp4, + webp, + withSignature, +} from "../../../helpers/media-fixtures"; + +describe("detectMediaType()", () => { + it("should recognise every accepted image format", () => { + expect(detectMediaType(JPEG)).toEqual({ + kind: MediaKind.IMAGE, + extension: "jpg", + mimeType: "image/jpeg", + }); + expect(detectMediaType(PNG)).toEqual({ + kind: MediaKind.IMAGE, + extension: "png", + mimeType: "image/png", + }); + expect(detectMediaType(GIF)).toEqual({ + kind: MediaKind.IMAGE, + extension: "gif", + mimeType: "image/gif", + }); + expect(detectMediaType(webp())).toEqual({ + kind: MediaKind.IMAGE, + extension: "webp", + mimeType: "image/webp", + }); + expect(detectMediaType(avif())).toEqual({ + kind: MediaKind.IMAGE, + extension: "avif", + mimeType: "image/avif", + }); + }); + + it("should recognise the accepted video containers", () => { + expect(detectMediaType(mp4())).toEqual({ + kind: MediaKind.VIDEO, + extension: "mp4", + mimeType: "video/mp4", + }); + expect(detectMediaType(mov())).toEqual({ + kind: MediaKind.VIDEO, + extension: "mov", + mimeType: "video/quicktime", + }); + expect(detectMediaType(WEBM)).toEqual({ + kind: MediaKind.VIDEO, + extension: "webm", + mimeType: "video/webm", + }); + }); + + it("should tell an AVIF still apart from a video in the same container", () => { + // Both carry an ftyp box; only the brand separates them, which is why + // the detector reads the brand rather than stopping at the box. + expect(detectMediaType(avif())?.kind).toBe(MediaKind.IMAGE); + expect(detectMediaType(mp4())?.kind).toBe(MediaKind.VIDEO); + }); + + it("should reject an MP4-family brand that is not on the allow list", () => { + // HEIC lives in the same container. Admitting anything with an ftyp box + // would let it, and the audio-only profiles, through as video. + expect(detectMediaType(isoMedia("heic"))).toBeNull(); + }); + + it("should reject scriptable and unknown formats", () => { + expect(detectMediaType(SVG)).toBeNull(); + expect(detectMediaType(HTML)).toBeNull(); + expect(detectMediaType(Buffer.alloc(0))).toBeNull(); + expect(detectMediaType(Buffer.from([0xff, 0xd8]))).toBeNull(); + }); + + it("should not mistake a RIFF container that is not WEBP for an image", () => { + // "RIFF" alone is a WAV as readily as a WEBP. + const wav = withSignature([0x52, 0x49, 0x46, 0x46]); + wav.write("WAVE", 8, "latin1"); + + expect(detectMediaType(wav)).toBeNull(); + }); +}); diff --git a/tests/unit/core/use-cases/shared/resolve-attachable-media.test.ts b/tests/unit/core/use-cases/shared/resolve-attachable-media.test.ts new file mode 100644 index 00000000..731f655e --- /dev/null +++ b/tests/unit/core/use-cases/shared/resolve-attachable-media.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MediaAsset } from "@core/domain/entities/media-asset.entity"; +import { + MediaChannel, + MediaKind, + MediaModerationStatus, +} from "@core/domain/enums"; +import { MediaNotOwnedError } from "@core/errors"; +import type { IMediaAssetRepository } from "@core/ports/repositories/media-asset.repository"; +import { resolveAttachableMedia } from "@core/use-cases/shared/media/resolve-attachable-media"; +import { toStorageKey } from "@core/use-cases/shared/media/media-url"; + +const CDN = "https://cdn.example.com"; +const OWNER = "user-1"; +const KEY = "posts/user-1/abc.jpg"; +const URL = `${CDN}/${KEY}`; + +/** + * Builds a stored asset, defaulting to one this owner may attach. + */ +function asset(overrides: Partial> = {}): MediaAsset { + return MediaAsset.with({ + id: "asset-1", + storageKey: KEY, + kind: MediaKind.IMAGE, + mimeType: "image/jpeg", + byteSize: 100, + uploaderId: OWNER, + channel: MediaChannel.POST_MEDIA, + status: MediaModerationStatus.APPROVED, + categories: [], + attempts: 0, + ...overrides, + }); +} + +describe("resolveAttachableMedia()", () => { + let mediaAssetRepository: Pick; + + const resolve = ( + mediaUrls: string[], + uploaderId = OWNER, + ): ReturnType => + resolveAttachableMedia({ + mediaUrls, + uploaderId, + channel: MediaChannel.POST_MEDIA, + cdnBaseUrl: CDN, + mediaAssetRepository: mediaAssetRepository as IMediaAssetRepository, + }); + + beforeEach(() => { + mediaAssetRepository = { + findByStorageKeys: vi.fn().mockResolvedValue([asset()]), + }; + }); + + it("should resolve a URL this uploader owns", async () => { + await expect(resolve([URL])).resolves.toEqual({ + storageKeys: [KEY], + isSensitive: false, + mediaStatus: MediaModerationStatus.APPROVED, + }); + }); + + it("should short-circuit when there is no media", async () => { + await expect(resolve([])).resolves.toEqual({ + storageKeys: [], + isSensitive: false, + mediaStatus: MediaModerationStatus.APPROVED, + }); + + expect(mediaAssetRepository.findByStorageKeys).not.toHaveBeenCalled(); + }); + + it("should refuse a URL nobody uploaded", async () => { + // This is the check that makes moderation mean anything: scanning at + // upload time governs the upload endpoint, not what a client puts in a + // post body. + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([]); + + await expect(resolve([URL])).rejects.toThrow(MediaNotOwnedError); + }); + + it("should refuse a URL pointing outside the CDN", async () => { + await expect( + resolve(["https://evil.example.com/whatever.jpg"]), + ).rejects.toThrow(MediaNotOwnedError); + }); + + it("should refuse someone else's key", async () => { + await expect(resolve([URL], "user-2")).rejects.toThrow( + MediaNotOwnedError, + ); + }); + + it("should refuse a key moderation already rejected", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + asset({ status: MediaModerationStatus.REJECTED }), + ]); + + await expect(resolve([URL])).rejects.toThrow(MediaNotOwnedError); + }); + + it("should refuse a key uploaded through a different endpoint", async () => { + // An avatar must not become post media: the two endpoints have + // different rules, and the channel is what records which applied. + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + asset({ channel: MediaChannel.AVATAR }), + ]); + + await expect(resolve([URL])).rejects.toThrow(MediaNotOwnedError); + }); + + it("should refuse a key some other content already claimed", async () => { + // One upload backs one post. Reusing a key would move it to the newest + // claimant, leaving the older post waiting on a verdict written + // elsewhere. + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + asset({ ownerId: "post-1" }), + ]); + + await expect(resolve([URL])).rejects.toThrow(MediaNotOwnedError); + }); + + it("should carry a sensitive asset onto the content", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + asset({ status: MediaModerationStatus.SENSITIVE }), + ]); + + await expect(resolve([URL])).resolves.toMatchObject({ + isSensitive: true, + mediaStatus: MediaModerationStatus.APPROVED, + }); + }); + + it("should hold the content pending while an attached video is unscanned", async () => { + vi.mocked(mediaAssetRepository.findByStorageKeys).mockResolvedValue([ + asset({ status: MediaModerationStatus.PENDING }), + ]); + + await expect(resolve([URL])).resolves.toMatchObject({ + mediaStatus: MediaModerationStatus.PENDING, + }); + }); +}); + +describe("toStorageKey()", () => { + it("should strip the CDN prefix", () => { + expect(toStorageKey(URL, CDN)).toBe(KEY); + expect(toStorageKey(URL, CDN + "/")).toBe(KEY); + }); + + it("should drop a cache-busting query", () => { + expect(toStorageKey(`${URL}?v=1`, CDN)).toBe(KEY); + }); + + it("should pass a bare key through", () => { + expect(toStorageKey(KEY, CDN)).toBe(KEY); + expect(toStorageKey("/" + KEY, CDN)).toBe(KEY); + }); + + it("should refuse a URL on another origin", () => { + expect(toStorageKey("https://evil.example.com/x.jpg", CDN)).toBeNull(); + // A prefix match alone is not enough: this host merely starts the same. + expect( + toStorageKey("https://cdn.example.com.evil.test/x.jpg", CDN), + ).toBeNull(); + }); + + it("should refuse traversal in either form", () => { + expect(toStorageKey("../../etc/passwd", CDN)).toBeNull(); + expect(toStorageKey(`${CDN}/../secret.jpg`, CDN)).toBeNull(); + }); + + it("should refuse an empty value", () => { + expect(toStorageKey(" ", CDN)).toBeNull(); + expect(toStorageKey(CDN + "/", CDN)).toBeNull(); + }); +}); diff --git a/tests/unit/helpers/media-fixtures.ts b/tests/unit/helpers/media-fixtures.ts new file mode 100644 index 00000000..bde134dd --- /dev/null +++ b/tests/unit/helpers/media-fixtures.ts @@ -0,0 +1,90 @@ +import { vi } from "vitest"; +import { MediaModerationStatus } from "@core/domain/enums"; +import type { + MediaModerationPort, + MediaModerationResult, +} from "@core/ports/services/media-moderation.port"; + +/** + * Builds a buffer beginning with the given signature bytes. + * + * @param signature - The leading bytes + * @param totalLength - How long the buffer should be + * @returns A zero-padded buffer carrying the signature + */ +export function withSignature(signature: number[], totalLength = 32): Buffer { + const buffer = Buffer.alloc(totalLength); + for (let i = 0; i < signature.length; i++) buffer[i] = signature[i]; + return buffer; +} + +/** + * Writes an ASCII marker into a buffer at the given offset. + * + * @param buffer - The buffer to write into + * @param offset - Where the marker starts + * @param marker - The ASCII text + * @returns The same buffer + */ +function withMarker(buffer: Buffer, offset: number, marker: string): Buffer { + buffer.write(marker, offset, "latin1"); + return buffer; +} + +export const JPEG = withSignature([0xff, 0xd8, 0xff]); +export const PNG = withSignature([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); +export const GIF = withSignature([0x47, 0x49, 0x46, 0x38]); +export const SVG = Buffer.from( + '', +); +export const HTML = Buffer.from(""); + +/** WEBP needs "RIFF" at 0 and "WEBP" at 8. */ +export function webp(): Buffer { + return withMarker(withSignature([0x52, 0x49, 0x46, 0x46]), 8, "WEBP"); +} + +/** An ISO base media file: "ftyp" at 4, then the brand. */ +export function isoMedia(brand: string): Buffer { + return withMarker(withMarker(Buffer.alloc(32), 4, "ftyp"), 8, brand); +} + +/** AVIF is a still in the MP4 container. */ +export function avif(): Buffer { + return isoMedia("avif"); +} + +export function mp4(): Buffer { + return isoMedia("isom"); +} + +export function mov(): Buffer { + return isoMedia("qt "); +} + +/** WEBM and MKV share the EBML header. */ +export const WEBM = withSignature([0x1a, 0x45, 0xdf, 0xa3]); + +/** + * Builds a moderation port stub returning a fixed verdict. + * + * @param verdict - What both methods should answer + * @returns A fake implementing the port + */ +export function fakeModeration( + verdict: MediaModerationResult["verdict"] = MediaModerationStatus.APPROVED, +): MediaModerationPort { + const result: MediaModerationResult = { + verdict, + categories: [], + scores: {}, + provider: "fake", + }; + + return { + moderateImage: vi.fn().mockResolvedValue(result), + moderateVideo: vi.fn().mockResolvedValue(result), + }; +} diff --git a/tests/unit/infrastructure/mappers/comment-prisma.mapper.test.ts b/tests/unit/infrastructure/mappers/comment-prisma.mapper.test.ts index 86126ae9..9ec227e5 100644 --- a/tests/unit/infrastructure/mappers/comment-prisma.mapper.test.ts +++ b/tests/unit/infrastructure/mappers/comment-prisma.mapper.test.ts @@ -4,6 +4,7 @@ import { type CommentWithRelations, } from "@infrastructure/persistence/mappers/comment-prisma.mapper"; import { Comment } from "@core/domain/entities/comment.entity"; +import { MediaModerationStatus } from "@core/domain/enums"; const CDN = "https://cdn.example.com"; const now = new Date("2025-01-01T00:00:00.000Z"); @@ -29,6 +30,8 @@ function makeDbComment( }, likes: [], bookmarks: [], + isSensitive: false, + mediaStatus: MediaModerationStatus.APPROVED, ...overrides, } as unknown as CommentWithRelations; } @@ -225,9 +228,8 @@ describe("CommentPrismaMapper", () => { describe("author handle guarantee", () => { it("should serialize the author handle", () => { - const comment = CommentPrismaMapper.toDomainComment( - makeDbComment(), - ); + const comment = + CommentPrismaMapper.toDomainComment(makeDbComment()); expect( CommentPrismaMapper.toResponse(comment, CDN).author.username, @@ -251,4 +253,44 @@ describe("CommentPrismaMapper", () => { ); }); }); + describe("moderated media", () => { + const MEDIA = ["https://cdn.example.com/posts/user-1/clip.mp4"]; + + it("should serve media once it has been cleared", () => { + const comment = CommentPrismaMapper.toDomainComment( + makeDbComment({ mediaUrls: MEDIA } as never), + ); + const result = CommentPrismaMapper.toResponse(comment, CDN); + + expect(result.mediaUrls).toEqual(MEDIA); + expect(result.mediaPending).toBe(false); + expect(result.isSensitive).toBe(false); + }); + + it("should withhold unscanned media but keep the text", () => { + // Comment media comes off the same upload endpoint as post media, + // so it is withheld on the same terms. + const comment = CommentPrismaMapper.toDomainComment( + makeDbComment({ + mediaUrls: MEDIA, + mediaStatus: MediaModerationStatus.PENDING, + } as never), + ); + const result = CommentPrismaMapper.toResponse(comment, CDN); + + expect(result.mediaUrls).toEqual([]); + expect(result.mediaPending).toBe(true); + expect(result.content).toBe("Test comment"); + }); + + it("should serve borderline media with the sensitive flag set", () => { + const comment = CommentPrismaMapper.toDomainComment( + makeDbComment({ mediaUrls: MEDIA, isSensitive: true } as never), + ); + const result = CommentPrismaMapper.toResponse(comment, CDN); + + expect(result.mediaUrls).toEqual(MEDIA); + expect(result.isSensitive).toBe(true); + }); + }); }); diff --git a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts index 0bcd5fc4..00d44785 100644 --- a/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts +++ b/tests/unit/infrastructure/mappers/post-prisma.mapper.test.ts @@ -6,6 +6,7 @@ import { import { Post } from "@core/domain/entities/post.entity"; import { PostType } from "@core/domain/enums/post-type.enum"; import { PostCategory } from "@core/domain/enums/post-category-enum"; +import { MediaModerationStatus } from "@core/domain/enums"; const CDN = "https://cdn.example.com"; const now = new Date("2025-01-01T00:00:00.000Z"); @@ -35,6 +36,8 @@ function makeDbPost( ], likes: [], bookmarks: [], + isSensitive: false, + mediaStatus: MediaModerationStatus.APPROVED, ...overrides, } as unknown as PostWithRelations; } @@ -357,7 +360,9 @@ describe("PostPrismaMapper", () => { expect(PostPrismaMapper.toPrismaPost(quote).quotedPostId).toBe( "post-0", ); - expect(PostPrismaMapper.toPrismaPost(plain).quotedPostId).toBeNull(); + expect( + PostPrismaMapper.toPrismaPost(plain).quotedPostId, + ).toBeNull(); }); it("should render the quote card with a CDN-resolved avatar", () => { @@ -373,6 +378,8 @@ describe("PostPrismaMapper", () => { id: "post-0", content: "The quoted post", mediaUrls: ["uploads/quoted.png"], + isSensitive: false, + mediaPending: false, createdAt: now, author: { id: "user-9", @@ -386,7 +393,9 @@ describe("PostPrismaMapper", () => { it("should send quotedPost as null for a post that quotes nothing", () => { const post = PostPrismaMapper.toDomainPost(makeDbPost()); - expect(PostPrismaMapper.toResponse(post, CDN).quotedPost).toBeNull(); + expect( + PostPrismaMapper.toResponse(post, CDN).quotedPost, + ).toBeNull(); }); it("should map quoteCount onto the entity and the response", () => { @@ -459,4 +468,107 @@ describe("PostPrismaMapper", () => { expect(response.lang).toBeNull(); }); }); + describe("moderated media", () => { + const MEDIA = ["https://cdn.example.com/posts/user-1/clip.mp4"]; + + it("should serve media once it has been cleared", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ mediaUrls: MEDIA } as never), + ); + const result = PostPrismaMapper.toResponse(post, CDN); + + expect(result.mediaUrls).toEqual(MEDIA); + expect(result.mediaPending).toBe(false); + expect(result.isSensitive).toBe(false); + }); + + it("should withhold media that is still being scanned, but keep the text", () => { + // The text is the author's and was never in question; a video + // usually clears within a minute of being uploaded. + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ + mediaUrls: MEDIA, + mediaStatus: MediaModerationStatus.PENDING, + } as never), + ); + const result = PostPrismaMapper.toResponse(post, CDN); + + expect(result.mediaUrls).toEqual([]); + expect(result.mediaPending).toBe(true); + expect(result.content).toBe("Hello world"); + }); + + it("should withhold media that was rejected, without calling it pending", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ + mediaUrls: MEDIA, + mediaStatus: MediaModerationStatus.REJECTED, + } as never), + ); + const result = PostPrismaMapper.toResponse(post, CDN); + + expect(result.mediaUrls).toEqual([]); + expect(result.mediaPending).toBe(false); + }); + + it("should serve borderline media with the sensitive flag set", () => { + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ mediaUrls: MEDIA, isSensitive: true } as never), + ); + const result = PostPrismaMapper.toResponse(post, CDN); + + expect(result.mediaUrls).toEqual(MEDIA); + expect(result.isSensitive).toBe(true); + }); + + it("should withhold unscanned media on a quote card too", () => { + // Otherwise quoting a post would be a way to publish its video + // before that video was cleared. + const post = PostPrismaMapper.toDomainPost( + makeDbPost({ + quotedPostId: "post-0", + quotedPost: { + id: "post-0", + content: "The quoted post", + mediaUrls: ["uploads/quoted.png"], + createdAt: now, + authorId: "user-9", + author: { + id: "user-9", + username: "quoted-author", + profile: { + avatarUrl: "uploads/quoted-avatar.jpg", + fullName: "Quoted Author", + }, + }, + isSensitive: false, + mediaStatus: MediaModerationStatus.PENDING, + }, + } as never), + ); + + expect( + PostPrismaMapper.toResponse(post, CDN).quotedPost?.mediaUrls, + ).toEqual([]); + }); + + it("should carry the moderation state onto the Prisma create payload", () => { + const post = Post.create( + "hi", + PostType.COMMUNITY, + "user-1", + MEDIA, + [], + undefined, + null, + true, + MediaModerationStatus.PENDING, + ); + + expect(PostPrismaMapper.toPrismaPost(post)).toMatchObject({ + isSensitive: true, + mediaStatus: MediaModerationStatus.PENDING, + }); + }); + }); }); diff --git a/tests/unit/infrastructure/moderation/score-to-verdict.test.ts b/tests/unit/infrastructure/moderation/score-to-verdict.test.ts new file mode 100644 index 00000000..9e38f86e --- /dev/null +++ b/tests/unit/infrastructure/moderation/score-to-verdict.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + MediaModerationCategory, + MediaModerationStatus, +} from "@core/domain/enums"; +import { scoreToVerdict } from "@infrastructure/external/moderation/score-to-verdict"; +import { + flattenSightengineFrames, + flattenSightengineScores, +} from "@infrastructure/external/moderation/sightengine-response"; + +const THRESHOLDS = { reject: 0.75, sensitive: 0.4 }; + +describe("scoreToVerdict()", () => { + it("should approve a clean file", () => { + expect( + scoreToVerdict( + { + "nudity.sexual_activity": 0.01, + "gore.prob": 0.02, + "violence.prob": 0.03, + }, + THRESHOLDS, + ), + ).toEqual({ + verdict: MediaModerationStatus.APPROVED, + categories: [], + }); + }); + + it("should reject explicit sexual content above the reject threshold", () => { + const result = scoreToVerdict( + { "nudity.sexual_activity": 0.9 }, + THRESHOLDS, + ); + + expect(result.verdict).toBe(MediaModerationStatus.REJECTED); + expect(result.categories).toContain( + MediaModerationCategory.SEXUAL_ACTIVITY, + ); + }); + + it("should reject gore above the reject threshold", () => { + expect(scoreToVerdict({ "gore.prob": 0.8 }, THRESHOLDS).verdict).toBe( + MediaModerationStatus.REJECTED, + ); + }); + + it("should mark suggestive content sensitive rather than rejecting it", () => { + // Suggestive is a blur, never a removal, no matter how confident the + // provider is. + const result = scoreToVerdict( + { "nudity.suggestive": 0.99 }, + THRESHOLDS, + ); + + expect(result.verdict).toBe(MediaModerationStatus.SENSITIVE); + expect(result.categories).toEqual([MediaModerationCategory.SUGGESTIVE]); + }); + + it("should keep weapons and depicted violence at sensitive", () => { + // A developer network is full of game screenshots. A filter that + // deletes those is a filter people route around. + expect( + scoreToVerdict({ "weapon.prob": 0.99 }, THRESHOLDS).verdict, + ).toBe(MediaModerationStatus.SENSITIVE); + expect( + scoreToVerdict({ "violence.prob": 0.99 }, THRESHOLDS).verdict, + ).toBe(MediaModerationStatus.SENSITIVE); + }); + + it("should ignore a score sitting below the sensitive threshold", () => { + expect( + scoreToVerdict({ "nudity.suggestive": 0.39 }, THRESHOLDS).verdict, + ).toBe(MediaModerationStatus.APPROVED); + }); + + it("should let a rejecting class win over a sensitive one", () => { + const result = scoreToVerdict( + { "nudity.suggestive": 0.9, "gore.prob": 0.9 }, + THRESHOLDS, + ); + + expect(result.verdict).toBe(MediaModerationStatus.REJECTED); + }); + + it("should ignore classes it does not know", () => { + // A provider adding a model must not silently start rejecting uploads. + expect( + scoreToVerdict({ "some-new-model.prob": 1 }, THRESHOLDS).verdict, + ).toBe(MediaModerationStatus.APPROVED); + }); + + it("should honour thresholds moved from the environment", () => { + expect( + scoreToVerdict( + { "gore.prob": 0.5 }, + { reject: 0.4, sensitive: 0.2 }, + ).verdict, + ).toBe(MediaModerationStatus.REJECTED); + }); +}); + +describe("flattenSightengineScores()", () => { + it("should flatten the provider's nested answer into dotted keys", () => { + expect( + flattenSightengineScores({ + nudity: { + sexual_activity: 0.9, + suggestive: 0.2, + none: 0.05, + context: { sea_lingerie: 0.1 }, + }, + gore: { prob: 0.3 }, + violence: { prob: 0.1 }, + }), + ).toEqual({ + "nudity.sexual_activity": 0.9, + "nudity.suggestive": 0.2, + "gore.prob": 0.3, + "violence.prob": 0.1, + }); + }); + + it("should read both shapes the weapon model has used", () => { + expect( + flattenSightengineScores({ + weapon: { classes: { firearm: 0.8, knife: 0.2 } }, + }), + ).toEqual({ "weapon.prob": 0.8 }); + + expect(flattenSightengineScores({ weapon: 0.6 })).toEqual({ + "weapon.prob": 0.6, + }); + }); + + it("should survive a response missing the models entirely", () => { + expect(flattenSightengineScores({ status: "success" })).toEqual({}); + }); +}); + +describe("flattenSightengineFrames()", () => { + it("should keep the worst score seen across frames", () => { + // A clip is exactly as acceptable as its worst frame: content that + // appears for a second is still published. + expect( + flattenSightengineFrames([ + { gore: { prob: 0.01 } }, + { gore: { prob: 0.92 } }, + { gore: { prob: 0.02 } }, + ]), + ).toEqual({ "gore.prob": 0.92 }); + }); +});