diff --git a/src/__tests__/ads.test.ts b/src/__tests__/ads.test.ts index fae6ff3..ab32c86 100644 --- a/src/__tests__/ads.test.ts +++ b/src/__tests__/ads.test.ts @@ -21,6 +21,7 @@ vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), })); vi.mock("@/lib/prisma", () => ({ prisma: {} })); +vi.mock("@/lib/redis", () => ({ redis: {} })); import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; diff --git a/src/__tests__/comments.test.ts b/src/__tests__/comments.test.ts index 9039dcb..29a853d 100644 --- a/src/__tests__/comments.test.ts +++ b/src/__tests__/comments.test.ts @@ -61,6 +61,7 @@ describe("comments.create", () => { const mockPet = { id: "pet-1", ownerId: "owner-1", name: "Buddy" }; mockPrisma.pet.findUnique.mockResolvedValue(mockPet); + mockPrisma.post.findUnique.mockResolvedValue({ petId: "other-pet" }); mockPrisma.comment.create.mockResolvedValue({ id: "cmt-1", postId: "post-1", diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 9cd9c9d..c7beccd 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -124,6 +124,7 @@ interface MockBlockClient { create: MockFn; findMany: MockFn; findUnique: MockFn; + findFirst: MockFn; delete: MockFn; count: MockFn; } @@ -417,10 +418,14 @@ export function createMockPrisma(): MockPrismaClient { count: vi.fn(), upsert: vi.fn(), }, + // assertNotBlocked (comments/reactions/reposts) checks this on every + // interaction — default to "not blocked" so unrelated tests don't need + // to stub it every time. block: { create: vi.fn(), findMany: vi.fn(), findUnique: vi.fn(), + findFirst: vi.fn().mockResolvedValue(null), delete: vi.fn(), count: vi.fn(), }, diff --git a/src/__tests__/reactions.test.ts b/src/__tests__/reactions.test.ts index b5be773..db82dd9 100644 --- a/src/__tests__/reactions.test.ts +++ b/src/__tests__/reactions.test.ts @@ -62,6 +62,7 @@ describe("reactions.toggle", () => { mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" }); mockPrisma.reaction.findUnique.mockResolvedValue(null); + mockPrisma.post.findUnique.mockResolvedValue({ petId: "other-pet" }); mockPrisma.reaction.create.mockResolvedValue({ id: "rxn-1", postId: "post-1", diff --git a/src/lib/assert-not-blocked.ts b/src/lib/assert-not-blocked.ts new file mode 100644 index 0000000..3e9afe0 --- /dev/null +++ b/src/lib/assert-not-blocked.ts @@ -0,0 +1,30 @@ +import "server-only"; +import { TRPCError } from "@trpc/server"; +import type { PrismaClient } from "@prisma/client"; + +/** + * Blocks apply in both directions and must gate direct interaction + * (comments, reactions, reposts), not just feed visibility — see + * blocks.ts's own doc comment on why feed hydration alone isn't enough. + */ +export async function assertNotBlocked( + prisma: PrismaClient, + petIdA: string, + petIdB: string +) { + if (petIdA === petIdB) return; + const block = await prisma.block.findFirst({ + where: { + OR: [ + { blockerPetId: petIdA, blockedPetId: petIdB }, + { blockerPetId: petIdB, blockedPetId: petIdA }, + ], + }, + }); + if (block) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Cannot interact with this pet's content.", + }); + } +} diff --git a/src/trpc/routers/ads.ts b/src/trpc/routers/ads.ts index f5fd2de..7531257 100644 --- a/src/trpc/routers/ads.ts +++ b/src/trpc/routers/ads.ts @@ -1,6 +1,6 @@ import "server-only"; import { z } from "zod"; -import { router, protectedProcedure } from "@/trpc/init"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { petIdentitySelect } from "@/repositories/pet-repository"; @@ -46,6 +46,7 @@ export const adsRouter = router({ * (a missed view increment shouldn't surface as a user-visible error). */ recordView: protectedProcedure + .use(rateLimited("ads.recordView", 100, 60)) .input(z.object({ adId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { await ctx.prisma.advertisement.update({ @@ -56,6 +57,7 @@ export const adsRouter = router({ }), toggleReaction: protectedProcedure + .use(rateLimited("ads.toggleReaction", 60, 60)) .input(z.object({ adId: z.string().min(1), petId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); @@ -100,6 +102,7 @@ export const adsRouter = router({ }), addComment: protectedProcedure + .use(rateLimited("ads.addComment", 20, 300)) .input( z.object({ adId: z.string().min(1), @@ -126,6 +129,7 @@ export const adsRouter = router({ }), toggleRepost: protectedProcedure + .use(rateLimited("ads.toggleRepost", 20, 300)) .input(z.object({ adId: z.string().min(1), petId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); diff --git a/src/trpc/routers/comments.ts b/src/trpc/routers/comments.ts index 1b82dd7..1e2b152 100644 --- a/src/trpc/routers/comments.ts +++ b/src/trpc/routers/comments.ts @@ -4,6 +4,7 @@ import { TRPCError } from "@trpc/server"; import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { createMentionNotifications } from "@/lib/mention-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { assertNotBlocked } from "@/lib/assert-not-blocked"; import { notify } from "@/lib/notify"; import { petIdentitySelect } from "@/repositories/pet-repository"; @@ -20,6 +21,13 @@ export const commentsRouter = router({ .mutation(async ({ ctx, input }) => { await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); + const targetPost = await ctx.prisma.post.findUnique({ + where: { id: input.postId }, + select: { petId: true }, + }); + if (!targetPost) throw new TRPCError({ code: "NOT_FOUND" }); + await assertNotBlocked(ctx.prisma, input.petId, targetPost.petId); + const [comment, post] = await ctx.prisma.$transaction([ ctx.prisma.comment.create({ data: { diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts index 1e233a8..2f8ceaa 100644 --- a/src/trpc/routers/follows.ts +++ b/src/trpc/routers/follows.ts @@ -8,6 +8,7 @@ import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { notify } from "@/lib/notify"; import { FOLLOWER_MILESTONE_THRESHOLDS as FOLLOWER_MILESTONES } from "@/lib/milestone-thresholds"; import { pickLocalizedName } from "@/lib/localized-name"; +import { getSpiderExclusionId } from "@/lib/spider-exclusion"; import { petIdentitySelect } from "@/repositories/pet-repository"; /** @@ -167,10 +168,12 @@ export const followsRouter = router({ }) ) .query(async ({ ctx, input }) => { + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const rows = await ctx.prisma.follow.findMany({ where: { followeePetId: input.petId, ...(input.cursor ? { createdAt: { lt: new Date(input.cursor) } } : {}), + ...(spiderExclusionId ? { follower: { NOT: { speciesId: spiderExclusionId } } } : {}), }, include: { follower: { @@ -210,12 +213,14 @@ export const followsRouter = router({ .input(z.object({ followerPetId: z.string().min(1) })) .query(async ({ ctx, input }) => { await assertPetOwnership(ctx.prisma, input.followerPetId, ctx.userId); + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const rows = await ctx.prisma.follow.findMany({ where: { followerPetId: input.followerPetId, followee: { stories: { some: { expiresAt: { gt: new Date() } } }, + ...(spiderExclusionId ? { NOT: { speciesId: spiderExclusionId } } : {}), }, }, select: { @@ -242,10 +247,12 @@ export const followsRouter = router({ }) ) .query(async ({ ctx, input }) => { + const spiderExclusionId = await getSpiderExclusionId(ctx.prisma, ctx.userId); const rows = await ctx.prisma.follow.findMany({ where: { followerPetId: input.petId, ...(input.cursor ? { createdAt: { lt: new Date(input.cursor) } } : {}), + ...(spiderExclusionId ? { followee: { NOT: { speciesId: spiderExclusionId } } } : {}), }, include: { followee: { diff --git a/src/trpc/routers/reactions.ts b/src/trpc/routers/reactions.ts index 0ad66c3..4853eeb 100644 --- a/src/trpc/routers/reactions.ts +++ b/src/trpc/routers/reactions.ts @@ -1,7 +1,9 @@ import "server-only"; import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { assertNotBlocked } from "@/lib/assert-not-blocked"; import { notify } from "@/lib/notify"; export const reactionsRouter = router({ @@ -28,6 +30,13 @@ export const reactionsRouter = router({ return { reacted: false }; } + const targetPost = await ctx.prisma.post.findUnique({ + where: { id: input.postId }, + select: { petId: true }, + }); + if (!targetPost) throw new TRPCError({ code: "NOT_FOUND" }); + await assertNotBlocked(ctx.prisma, input.petId, targetPost.petId); + const [, post] = await ctx.prisma.$transaction([ ctx.prisma.reaction.create({ data: { postId: input.postId, petId: input.petId }, diff --git a/src/trpc/routers/reposts.ts b/src/trpc/routers/reposts.ts index fa78121..a807e35 100644 --- a/src/trpc/routers/reposts.ts +++ b/src/trpc/routers/reposts.ts @@ -6,6 +6,7 @@ import { redis } from "@/lib/redis"; import { fanOutPost } from "@/lib/feed-helpers"; import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { assertNotBlocked } from "@/lib/assert-not-blocked"; import { notify } from "@/lib/notify"; export const repostsRouter = router({ @@ -33,6 +34,8 @@ export const repostsRouter = router({ }); } + await assertNotBlocked(ctx.prisma, input.reposterPetId, originalPost.petId); + // Idempotency: return existing repost postId without duplicate create const existing = await ctx.prisma.repost.findUnique({ where: { diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts index cc3cff1..e9f59a9 100644 --- a/src/trpc/routers/stories.ts +++ b/src/trpc/routers/stories.ts @@ -261,12 +261,11 @@ export const storiesRouter = router({ .query(async ({ ctx, input }) => { const story = await ctx.prisma.story.findUnique({ where: { id: input.storyId }, - select: { id: true, pet: { select: { ownerId: true } } }, + select: { id: true, petId: true }, }); - if (!story || story.pet.ownerId !== ctx.userId) { - throw new TRPCError({ code: "FORBIDDEN" }); - } + if (!story) throw new TRPCError({ code: "FORBIDDEN" }); + await assertPetOwnership(ctx.prisma, story.petId, ctx.userId); return ctx.prisma.storyView.findMany({ where: { storyId: input.storyId }, diff --git a/src/trpc/routers/videos.ts b/src/trpc/routers/videos.ts index 91956bc..694c0dd 100644 --- a/src/trpc/routers/videos.ts +++ b/src/trpc/routers/videos.ts @@ -150,11 +150,10 @@ export const videosRouter = router({ .mutation(async ({ ctx, input }) => { const post = await ctx.prisma.post.findUnique({ where: { id: input.postId }, - select: { pet: { select: { ownerId: true } } }, + select: { petId: true }, }); - if (!post || post.pet.ownerId !== ctx.userId) { - throw new TRPCError({ code: "FORBIDDEN" }); - } + if (!post) throw new TRPCError({ code: "FORBIDDEN" }); + await assertPetOwnership(ctx.prisma, post.petId, ctx.userId); await ctx.prisma.post.update({ where: { id: input.postId }, data: { caption: input.caption },