From a27aa645b22f44e11cdea19639556458f5e055b4 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 1 Sep 2026 07:20:33 +0300 Subject: [PATCH 1/3] fix(post): stop the feed ending where the ranked window does Reported from production: a Turkish reader on the community feed was handed the two Turkish posts that exist and then told there was nothing left, while hundreds of older posts sat behind them. Two bugs compounding. The ranked window is a bounded pool of recent posts, and on a quiet feed it holds fewer than a page. The page ended there. The chronological tail was only reached on the *next* request, which the reader never made because of the second bug: hasMore was inferred from whether the page came back full. A short page meant "end of feed", which is wrong at exactly the boundary where the ranked window runs out mid-page - and that boundary is the normal case whenever a reader's language is scarce. A ranked page too short for the limit is now topped up from the tail in the same request, skipping everything the snapshot holds so nothing repeats, and counting in the snapshot's coordinates so the next page continues rather than restarting the tail. hasMore is read straight off the cursor: the use case knows when it has run out and says so by returning none, instead of the controller guessing from a row count. The tail also reports exhaustion honestly now. It used to keep offering a cursor for any non-empty page, so the last page always promised one more. The foreign-language quota drops to 0.2, which is the 80/20 split asked for. Worth being precise about what it is: a ceiling on content the reader cannot read, applied only while there is content in their own language to spend the other slots on. It has never been a floor, and with two Turkish posts in the corpus it cannot be - the feed serves those two first and then keeps going rather than running dry, which is the behaviour that was actually wanted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LP54iXPnpBLkTfg2te3hcn --- .env.example | 2 +- .../post/get-posts/get-posts.usecase.ts | 106 +++++++++--- src/http/controllers/post.controller.ts | 14 +- src/http/types/schemas/env.schema.ts | 6 +- tests/e2e/post/get-feed.test.ts | 38 ++++ .../core/use-cases/post/feed-ranking.test.ts | 41 +++++ .../use-cases/post/get-posts.usecase.test.ts | 162 ++++++++++++++++++ 7 files changed, 336 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index cd7d2c3..a7f65ab 100644 --- a/.env.example +++ b/.env.example @@ -64,7 +64,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/src/core/use-cases/post/get-posts/get-posts.usecase.ts b/src/core/use-cases/post/get-posts/get-posts.usecase.ts index 05579b7..a368c67 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/http/controllers/post.controller.ts b/src/http/controllers/post.controller.ts index 54cf359..1793d0e 100644 --- a/src/http/controllers/post.controller.ts +++ b/src/http/controllers/post.controller.ts @@ -197,12 +197,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/types/schemas/env.schema.ts b/src/http/types/schemas/env.schema.ts index d6624ba..e538d03 100644 --- a/src/http/types/schemas/env.schema.ts +++ b/src/http/types/schemas/env.schema.ts @@ -84,8 +84,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/tests/e2e/post/get-feed.test.ts b/tests/e2e/post/get-feed.test.ts index 478441e..e205bf8 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/unit/core/use-cases/post/feed-ranking.test.ts b/tests/unit/core/use-cases/post/feed-ranking.test.ts index aad7d3d..f1c3a17 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 0ec9b63..94a3dd5 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" })]; From cc1b5472cca95268fe5761ef04a6c8601d563461 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 1 Sep 2026 13:47:53 +0300 Subject: [PATCH 2/3] feat(media): record and moderate every stored file Adds the media_assets table and the moderation provider behind it. A row per stored file carries who uploaded it, through which endpoint, and what the provider said - kept with the raw class scores, so thresholds can be retuned against real traffic rather than against guesses. Verdicts are tiered rather than binary. Explicit sexual content, gore, self-harm and hate imagery are refused; suggestive content, weapons and depicted violence only mark the media sensitive, because a developer network is full of game screenshots and a filter that deletes those is one people route around. Videos cannot be judged inside a request, so they are claimed by a cron worker with FOR UPDATE SKIP LOCKED and a lease: several API instances can run the same schedule without paying twice for one verdict, and a process killed mid-scan releases its claim instead of hiding a post's media forever. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01A2WFyQ3PR2jYvDVk89yZpc --- .../migration.sql | 105 ++++++ prisma/models/article.prisma | 7 + prisma/models/media.prisma | 84 +++++ prisma/models/notification.prisma | 1 + prisma/models/post.prisma | 23 +- prisma/models/user.prisma | 1 + src/core/domain/entities/article.entity.ts | 18 + src/core/domain/entities/comment.entity.ts | 38 ++ .../domain/entities/media-asset.entity.ts | 171 +++++++++ src/core/domain/entities/post.entity.ts | 38 ++ src/core/domain/enums/index.ts | 6 + src/core/domain/enums/media-channel.enum.ts | 27 ++ src/core/domain/enums/media-kind.enum.ts | 18 + .../enums/media-moderation-category.enum.ts | 49 +++ .../enums/media-moderation-status.enum.ts | 48 +++ .../domain/enums/media-owner-kind.enum.ts | 15 + .../domain/enums/notification-type.enum.ts | 6 + .../interfaces/article-props.interface.ts | 10 + .../interfaces/comment-props.interface.ts | 17 + .../interfaces/media-asset-props.interface.ts | 62 ++++ .../domain/interfaces/post-props.interface.ts | 17 +- .../interfaces/quoted-post.interface.ts | 12 + src/core/errors/index.ts | 5 + .../errors/media/media-not-owned.error.ts | 24 ++ src/core/errors/media/media-rejected.error.ts | 23 ++ .../media/moderation-unavailable.error.ts | 23 ++ .../ports/repositories/comment.repository.ts | 15 + .../repositories/media-asset.repository.ts | 140 ++++++++ .../ports/repositories/post.repository.ts | 15 + src/core/ports/services/logger.port.ts | 12 + .../ports/services/media-moderation.port.ts | 70 ++++ src/core/ports/services/transaction.port.ts | 11 + src/infrastructure/external/logger.service.ts | 4 + .../moderation/noop-moderation.service.ts | 54 +++ .../external/moderation/score-to-verdict.ts | 154 ++++++++ .../sightengine-moderation.service.ts | 209 +++++++++++ .../moderation/sightengine-response.ts | 127 +++++++ .../media-moderation/media-moderation.job.ts | 27 ++ .../media-moderation.scheduler.ts | 85 +++++ .../database/transaction.service.ts | 2 + .../mappers/article-prisma.mapper.ts | 7 + .../mappers/comment-prisma.mapper.ts | 13 +- .../mappers/media-asset-prisma.mapper.ts | 85 +++++ .../persistence/mappers/post-prisma.mapper.ts | 35 +- .../repositories/prisma-comment.repository.ts | 24 ++ .../prisma-media-asset.repository.ts | 227 ++++++++++++ .../repositories/prisma-post.repository.ts | 22 ++ .../prisma-media-asset.repository.test.ts | 339 ++++++++++++++++++ tests/unit/core/domain/enums/enums.test.ts | 8 +- .../mappers/comment-prisma.mapper.test.ts | 48 ++- .../mappers/post-prisma.mapper.test.ts | 116 +++++- .../moderation/score-to-verdict.test.ts | 154 ++++++++ 52 files changed, 2835 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20260901000000_add_media_moderation/migration.sql create mode 100644 prisma/models/media.prisma create mode 100644 src/core/domain/entities/media-asset.entity.ts create mode 100644 src/core/domain/enums/media-channel.enum.ts create mode 100644 src/core/domain/enums/media-kind.enum.ts create mode 100644 src/core/domain/enums/media-moderation-category.enum.ts create mode 100644 src/core/domain/enums/media-moderation-status.enum.ts create mode 100644 src/core/domain/enums/media-owner-kind.enum.ts create mode 100644 src/core/domain/interfaces/media-asset-props.interface.ts create mode 100644 src/core/errors/media/media-not-owned.error.ts create mode 100644 src/core/errors/media/media-rejected.error.ts create mode 100644 src/core/errors/media/moderation-unavailable.error.ts create mode 100644 src/core/ports/repositories/media-asset.repository.ts create mode 100644 src/core/ports/services/media-moderation.port.ts create mode 100644 src/infrastructure/external/moderation/noop-moderation.service.ts create mode 100644 src/infrastructure/external/moderation/score-to-verdict.ts create mode 100644 src/infrastructure/external/moderation/sightengine-moderation.service.ts create mode 100644 src/infrastructure/external/moderation/sightengine-response.ts create mode 100644 src/infrastructure/jobs/media-moderation/media-moderation.job.ts create mode 100644 src/infrastructure/jobs/media-moderation/media-moderation.scheduler.ts create mode 100644 src/infrastructure/persistence/mappers/media-asset-prisma.mapper.ts create mode 100644 src/infrastructure/persistence/repositories/prisma-media-asset.repository.ts create mode 100644 tests/integration/persistence/repositories/prisma-media-asset.repository.test.ts create mode 100644 tests/unit/infrastructure/moderation/score-to-verdict.test.ts 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 0000000..768c291 --- /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 ff71946..d5c70c0 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 0000000..991c913 --- /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 7992ad3..f67b4ae 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 880d5ab..e8cff01 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 65062e9..20872a8 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/src/core/domain/entities/article.entity.ts b/src/core/domain/entities/article.entity.ts index 5b98c3a..d0a9f0d 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 10f1ef1..76c9fd8 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 0000000..ff065d4 --- /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 4e3c016..63349cf 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 e4495cd..6ba44b9 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 0000000..0c7ccb1 --- /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 0000000..1e96b54 --- /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 0000000..3d2777e --- /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 0000000..d7293dd --- /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 0000000..30889e0 --- /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 904d187..37a0e00 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 13a6789..9e7b1d0 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 b67185b..edaac21 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 0000000..0066301 --- /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 1a8d0dc..0a3e114 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 e300b39..58dd644 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 ace833f..59e8f32 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 0000000..c449bbd --- /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 0000000..d6576a7 --- /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 0000000..7f6824b --- /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 f432595..2ef8de9 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 0000000..703f66c --- /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 cefae83..1556ade 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 f906b2b..3eda81f 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 0000000..30dfb42 --- /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 ae33a34..386734a 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/infrastructure/external/logger.service.ts b/src/infrastructure/external/logger.service.ts index 8a625af..e253ae1 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 0000000..1e45e6d --- /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 0000000..55d13ef --- /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 0000000..a6c53de --- /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 0000000..b620e83 --- /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 0000000..196599a --- /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 0000000..9b40dbe --- /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 f759b99..bdfc083 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 08f569e..b974a02 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 251b0b5..362ea42 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 0000000..a4e9e11 --- /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 01d0218..0852e76 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 c8aa164..0a6259c 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 0000000..2826896 --- /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 5687657..79b988e 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/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 0000000..cf74e79 --- /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 f1b7011..17acab1 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/infrastructure/mappers/comment-prisma.mapper.test.ts b/tests/unit/infrastructure/mappers/comment-prisma.mapper.test.ts index 86126ae..9ec227e 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 0bcd5fc..00d4478 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 0000000..9e38f86 --- /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 }); + }); +}); From b9a9474e013484c422dcf795dd95d93db56aed01 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 1 Sep 2026 13:48:32 +0300 Subject: [PATCH 3/3] feat(media): route every upload through moderation Post and comment media, article covers, avatars and banners now share one upload path. It reads the format from the file's magic bytes instead of the client's MIME type and filename, both of which the uploader controls: a request can claim image/png while carrying an SVG, and a name like clip.png.html reads as an image to an extension check. Images are scanned before a byte reaches storage, so a refused file never gets a URL; videos are stored withheld and left to the worker. Content creation resolves every submitted media URL back to an asset row and refuses it unless this author uploaded it, through the matching channel, and nothing else has claimed it. This is what makes the rest of the pipeline mean anything: scanning at upload time only governs the upload endpoint, and a client is free to skip that endpoint and put its own URLs straight into a post body. The read path withholds media that has no verdict yet but keeps serving the text - the words were never in question, and a video usually clears within a minute - and reports isSensitive so a client can blur what moderation judged borderline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01A2WFyQ3PR2jYvDVk89yZpc --- .env.example | 24 ++ CLAUDE.md | 13 + render.yaml | 6 + src/app.ts | 2 + .../create-article/create-article.usecase.ts | 31 +- .../get-articles/get-articles.usecase.ts | 8 + .../update-article/update-article.usecase.ts | 41 +- .../upload-article-cover/detect-image-type.ts | 67 +-- .../upload-article-cover.usecase.ts | 60 +-- .../create-comment/create-comment.usecase.ts | 44 ++ .../media/moderate-pending-media/index.ts | 2 + .../moderate-pending-media.output.ts | 22 + .../moderate-pending-media.usecase.ts | 380 ++++++++++++++++++ .../media/upload-moderated-media/index.ts | 3 + .../upload-moderated-media-usecase.input.ts | 43 ++ .../upload-moderated-media-usecase.output.ts | 21 + .../upload-moderated-media.usecase.ts | 218 ++++++++++ .../post/create-post/create-post.usecase.ts | 48 ++- .../upload-post-media-usecase.input.ts | 16 +- .../upload-post-media.usecase.ts | 60 +-- .../update-avatar-usecase.input.ts | 14 +- .../update-avatar/update-avatar.usecase.ts | 58 +-- .../update-banner-usecase.input.ts | 18 +- .../update-banner/update-banner.usecase.ts | 57 +-- .../shared/media/detect-media-type.ts | 167 ++++++++ src/core/use-cases/shared/media/media-url.ts | 49 +++ .../shared/media/resolve-attachable-media.ts | 107 +++++ .../shared/media/resolve-cover-sensitivity.ts | 36 ++ src/http/controllers/post.controller.ts | 6 +- src/http/controllers/profile.controller.ts | 6 +- .../plugins/custom/media-moderation.plugin.ts | 44 ++ src/http/plugins/di/external.di.ts | 27 ++ src/http/plugins/di/jobs.di.ts | 13 + src/http/plugins/di/persistence.di.ts | 7 + src/http/plugins/di/use-cases.di.ts | 55 ++- src/http/types/fastify-awilix.d.ts | 12 + .../schemas/article/article-item.schema.ts | 3 + .../schemas/comment/get-comment.schema.ts | 7 + src/http/types/schemas/env.schema.ts | 37 ++ .../types/schemas/post/get-post.schema.ts | 14 + tests/e2e/post/media-moderation.test.ts | 209 ++++++++++ .../article/create-article.usecase.test.ts | 78 ++++ .../article/update-article.usecase.test.ts | 32 ++ .../upload-article-cover.usecase.test.ts | 28 +- .../create-article-comment.usecase.test.ts | 15 + .../comment/create-comment.usecase.test.ts | 14 + .../moderate-pending-media.usecase.test.ts | 375 +++++++++++++++++ .../upload-moderated-media.usecase.test.ts | 223 ++++++++++ .../post/create-post.usecase.test.ts | 139 ++++++- .../post/delete-post.usecase.test.ts | 1 + .../post/upload-post-media.usecase.test.ts | 98 ++--- .../profile/update-avatar.usecase.test.ts | 91 +++-- .../profile/update-banner.usecase.test.ts | 127 +++--- .../shared/detect-media-type.test.ts | 93 +++++ .../shared/resolve-attachable-media.test.ts | 180 +++++++++ tests/unit/helpers/media-fixtures.ts | 90 +++++ 56 files changed, 3254 insertions(+), 385 deletions(-) create mode 100644 src/core/use-cases/media/moderate-pending-media/index.ts create mode 100644 src/core/use-cases/media/moderate-pending-media/moderate-pending-media.output.ts create mode 100644 src/core/use-cases/media/moderate-pending-media/moderate-pending-media.usecase.ts create mode 100644 src/core/use-cases/media/upload-moderated-media/index.ts create mode 100644 src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.input.ts create mode 100644 src/core/use-cases/media/upload-moderated-media/upload-moderated-media-usecase.output.ts create mode 100644 src/core/use-cases/media/upload-moderated-media/upload-moderated-media.usecase.ts create mode 100644 src/core/use-cases/shared/media/detect-media-type.ts create mode 100644 src/core/use-cases/shared/media/media-url.ts create mode 100644 src/core/use-cases/shared/media/resolve-attachable-media.ts create mode 100644 src/core/use-cases/shared/media/resolve-cover-sensitivity.ts create mode 100644 src/http/plugins/custom/media-moderation.plugin.ts create mode 100644 tests/e2e/post/media-moderation.test.ts create mode 100644 tests/unit/core/use-cases/media/moderate-pending-media.usecase.test.ts create mode 100644 tests/unit/core/use-cases/media/upload-moderated-media.usecase.test.ts create mode 100644 tests/unit/core/use-cases/shared/detect-media-type.test.ts create mode 100644 tests/unit/core/use-cases/shared/resolve-attachable-media.test.ts create mode 100644 tests/unit/helpers/media-fixtures.ts diff --git a/.env.example b/.env.example index a7f65ab..205956d 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 diff --git a/CLAUDE.md b/CLAUDE.md index 251cbd2..b8abc29 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/render.yaml b/render.yaml index 330a80d..b7002b4 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 fdea309..c881481 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/use-cases/article/create-article/create-article.usecase.ts b/src/core/use-cases/article/create-article/create-article.usecase.ts index 7937c81..8cff928 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 e0b991f..6a45b0f 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 a0f6374..474eb0b 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 9839ff6..1a4f1c1 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 3054c6e..af48f4d 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 3298231..cf77926 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 0000000..d80335b --- /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 0000000..8691b68 --- /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 0000000..7c4bc99 --- /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 0000000..7d2b49c --- /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 0000000..e40cc4f --- /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 0000000..0d51445 --- /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 0000000..e95af03 --- /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 50c6be8..151c31a 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/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 86e8e4d..250ca62 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 5b5c6e1..69b0d8c 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 fe1a0be..2637637 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 b9eabe8..9d6850d 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 3f0213b..0f849f8 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 31fbd5f..6a0674e 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 0000000..0274e1d --- /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 0000000..d929459 --- /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 0000000..258b849 --- /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 0000000..e84504a --- /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 1793d0e..7c8b6e0 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}`); diff --git a/src/http/controllers/profile.controller.ts b/src/http/controllers/profile.controller.ts index 376e10e..de28303 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 0000000..43e9710 --- /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 bf53b20..0f3d5d9 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 b9bca69..23c4b4e 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 45ab0de..b97ccd5 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 ca4f858..7a3b099 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 910d02c..15171db 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 45305b1..56ea8b1 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 69a3d7f..220b331 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 e538d03..eec852d 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. diff --git a/src/http/types/schemas/post/get-post.schema.ts b/src/http/types/schemas/post/get-post.schema.ts index 7f31b99..3563cd2 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/tests/e2e/post/media-moderation.test.ts b/tests/e2e/post/media-moderation.test.ts new file mode 100644 index 0000000..645ded6 --- /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/unit/core/use-cases/article/create-article.usecase.test.ts b/tests/unit/core/use-cases/article/create-article.usecase.test.ts index ded756a..28f8a09 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 75b878f..61a0803 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 ec0f6fe..c69a5ce 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 346914b..d491d06 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 625e69b..3984c85 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 0000000..2bb82d0 --- /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 0000000..19b59f9 --- /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 09ef5df..8555e5b 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 79f902c..4b8fe13 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/upload-post-media.usecase.test.ts b/tests/unit/core/use-cases/post/upload-post-media.usecase.test.ts index bc01689..01d6b15 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 21de204..8bc479b 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 34d4a29..551b231 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 0000000..b862c0c --- /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 0000000..731f655 --- /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 0000000..bde134d --- /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), + }; +}