From bf49024ace5efe7012708d9440f532bcd09fc9e4 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Wed, 22 Jul 2026 19:24:21 +0200 Subject: [PATCH] fix: 6 HIGH-severity findings from 2026-07-21 audit rerun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feed.ts: Postgres fallback path (used when Redis is down) never filtered blocked pets, unlike the primary Redis path — blocked pets' posts could reappear during a Redis hiccup. - feed-helpers.ts/feed.ts: getFeedPage always paginated by a hardcoded PAGE_SIZE=20, silently ignoring the client's requested (zod-validated 1-50) limit whenever Redis was up. - milestones.ts: create accepted any MilestoneType including the follower-threshold values (FOLLOWERS_500..FOLLOWERS_1M), which are meant to be exclusively auto-awarded — restriction existed only in the MilestoneForm picker, not server-side. Now validated against the existing USER_INITIATED_MILESTONE_TYPES constant. - admin.ts: deletePost/deleteReportedPost didn't decrement the original post's repostCount when the deleted post was itself a repost, unlike reposts.ts's user-facing delete — counter drifted upward permanently. - PawButton.tsx: count was optimistically guessed off a `reacted` flag that can be stale (callers pass initialReacted={false} unconditionally), permanently corrupting the displayed count on click. Count is now computed from the server's actual toggle result instead of guessed. - PostDetailDialog.tsx: Report/Block dropdown items had no onClick and neither ReportSheet nor BlockDialog were rendered — wired up to match PostCard.tsx's existing pattern. - VideoCard.tsx: invalidated the entire query cache on every video-ready transition; scoped to feed/posts/explore router queries instead. --- src/__tests__/admin.test.ts | 22 +++++++++++ src/components/feed/PawButton.tsx | 27 +++++++++----- src/components/feed/PostDetailDialog.tsx | 37 ++++++++++++++++++- .../post-creation/MilestoneForm.tsx | 4 +- src/components/video/VideoCard.tsx | 14 ++++++- src/lib/feed-helpers.ts | 9 +++-- src/trpc/routers/admin.ts | 34 +++++++++++++++++ src/trpc/routers/feed.ts | 19 +++++++--- src/trpc/routers/milestones.ts | 9 +++-- 9 files changed, 146 insertions(+), 29 deletions(-) diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts index b49298d..9e7c20b 100644 --- a/src/__tests__/admin.test.ts +++ b/src/__tests__/admin.test.ts @@ -104,6 +104,7 @@ describe("admin transactional moderation actions", () => { it("deletePost wraps delete + log in a transaction", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null }); mockPrisma.post.delete.mockResolvedValue({ id: "post-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); @@ -111,6 +112,26 @@ describe("admin transactional moderation actions", () => { expect(mockPrisma.$transaction).toHaveBeenCalledWith(expect.any(Array)); expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "post-1" } }); + expect(mockPrisma.post.update).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it("deletePost decrements the original post's repostCount when the deleted post is itself a repost (2026-07-22 audit fix)", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ + repost: { originalPostId: "original-post-1" }, + }); + mockPrisma.post.delete.mockResolvedValue({ id: "repost-post-1" }); + mockPrisma.post.update.mockResolvedValue({ id: "original-post-1" }); + mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); + + const result = await caller.admin.deletePost({ postId: "repost-post-1" }); + + expect(mockPrisma.post.update).toHaveBeenCalledWith({ + where: { id: "original-post-1" }, + data: { repostCount: { decrement: 1 } }, + }); expect(result).toEqual({ success: true }); }); @@ -157,6 +178,7 @@ describe("admin transactional moderation actions", () => { it("deleteReportedPost wraps delete + log in a transaction", async () => { vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); const { caller, mockPrisma } = await createAdminCaller(); + mockPrisma.post.findUnique.mockResolvedValue({ repost: null }); mockPrisma.post.delete.mockResolvedValue({ id: "post-1" }); mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); diff --git a/src/components/feed/PawButton.tsx b/src/components/feed/PawButton.tsx index dcfb4a5..abddf30 100644 --- a/src/components/feed/PawButton.tsx +++ b/src/components/feed/PawButton.tsx @@ -21,19 +21,26 @@ export function PawButton({ postId, petId, initialCount, initialReacted }: PawBu const toggleMutation = useMutation( trpc.reactions.toggle.mutationOptions({ onMutate: () => { - // Optimistic update - const nextReacted = !reacted; - setReacted(nextReacted); - setCount((c) => (nextReacted ? c + 1 : Math.max(0, c - 1))); + const prevReacted = reacted; + const prevCount = count; + // Optimistically flip the icon only — `reacted` can be stale (callers + // currently always pass initialReacted={false}), so count is corrected + // precisely from the server's actual `data.reacted` in onSuccess + // instead of guessing a +/-1 delta off a possibly-wrong prior state. + setReacted(!reacted); + return { prevReacted, prevCount }; }, - onSuccess: (data) => { - // Sync with server truth + onSuccess: (data, _vars, context) => { setReacted(data.reacted); + if (context) { + setCount(data.reacted ? context.prevCount + 1 : Math.max(0, context.prevCount - 1)); + } }, - onError: () => { - // Revert optimistic update - setReacted(reacted); - setCount(initialCount); + onError: (_err, _vars, context) => { + if (context) { + setReacted(context.prevReacted); + setCount(context.prevCount); + } }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] }); diff --git a/src/components/feed/PostDetailDialog.tsx b/src/components/feed/PostDetailDialog.tsx index 75e90ef..df7a957 100644 --- a/src/components/feed/PostDetailDialog.tsx +++ b/src/components/feed/PostDetailDialog.tsx @@ -46,6 +46,8 @@ import { PawRing } from "@/components/stories/PawRing"; import { VideoCard } from "@/components/video/VideoCard"; import type { PostCardPost } from "@/components/feed/PostCard"; import { useTranslations } from "next-intl"; +import { BlockDialog } from "@/components/safety/BlockDialog"; +import { ReportSheet } from "@/components/safety/ReportSheet"; interface PostDetailDialogProps { post: PostCardPost | null; @@ -68,6 +70,8 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD const [editCaptionValue, setEditCaptionValue] = useState(""); const [currentCaption, setCurrentCaption] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showReportSheet, setShowReportSheet] = useState(false); + const [showBlockDialog, setShowBlockDialog] = useState(false); const [body, setBody] = useState(""); const [pendingBody, setPendingBody] = useState(null); const commentsEndRef = useRef(null); @@ -223,9 +227,17 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD ) : ( <> - {t("reportPost")} + setShowReportSheet(true)} + className="cursor-pointer" + > + {t("reportPost")} + - + setShowBlockDialog(true)} + className="cursor-pointer text-destructive focus:text-destructive" + > {t("blockPet", { name: pet.name })} @@ -508,6 +520,27 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD + + {/* ReportSheet — other's post */} + {activePetId && !isOwnPost && ( + + )} + + {/* BlockDialog — other's post */} + {activePetId && !isOwnPost && ( + + )} ); } diff --git a/src/components/post-creation/MilestoneForm.tsx b/src/components/post-creation/MilestoneForm.tsx index cc0fac2..953246f 100644 --- a/src/components/post-creation/MilestoneForm.tsx +++ b/src/components/post-creation/MilestoneForm.tsx @@ -43,7 +43,9 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) { const fileInputRef = useRef(null); const [pickerOpen, setPickerOpen] = useState(false); - const [selectedType, setSelectedType] = useState(null); + const [selectedType, setSelectedType] = useState< + (typeof USER_INITIATED_MILESTONE_TYPES)[number] | null + >(null); const [notes, setNotes] = useState(""); const [caption, setCaption] = useState(""); diff --git a/src/components/video/VideoCard.tsx b/src/components/video/VideoCard.tsx index e06ceb8..4184153 100644 --- a/src/components/video/VideoCard.tsx +++ b/src/components/video/VideoCard.tsx @@ -40,10 +40,20 @@ export function VideoCard({ postId, initialStatus, initialPlaybackId }: VideoCar const status = data?.status ?? initialStatus; const playbackId = data?.muxPlaybackId ?? initialPlaybackId; - // Invalidate the parent feed/post query once the video becomes ready + // Invalidate the parent feed/post query once the video becomes ready. + // VideoCard is rendered from PostCard/PostDetailDialog across several + // surfaces (feed, profile, explore) so it doesn't know the exact parent + // query — scope invalidation to those post-listing routers instead of + // wiping the entire cache (notifications, messages, etc. don't need it). useEffect(() => { if (status === "READY" && initialStatus === "PROCESSING") { - void queryClient.invalidateQueries(); + void queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey as unknown[]; + const routerSegment = Array.isArray(key[0]) ? key[0][1] : undefined; + return routerSegment === "feed" || routerSegment === "posts" || routerSegment === "explore"; + }, + }); } }, [status, initialStatus, queryClient]); diff --git a/src/lib/feed-helpers.ts b/src/lib/feed-helpers.ts index 54263f6..7350929 100644 --- a/src/lib/feed-helpers.ts +++ b/src/lib/feed-helpers.ts @@ -79,7 +79,8 @@ export async function backfillOnFollow( export async function getFeedPage( redis: Redis, petId: string, - cursor: number | null + cursor: number | null, + limit: number = PAGE_SIZE ): Promise<{ postIds: string[]; nextCursor: number | null }> { const maxScore = cursor ?? Date.now(); const minScore = 0; @@ -92,7 +93,7 @@ export async function getFeedPage( "WITHSCORES", "LIMIT", 0, - PAGE_SIZE + 1 + limit + 1 )) as string[]; const results: Array<{ member: string; score: number }> = []; @@ -100,8 +101,8 @@ export async function getFeedPage( results.push({ member: raw[i], score: parseFloat(raw[i + 1]) }); } - const hasNextPage = results.length > PAGE_SIZE; - const items = hasNextPage ? results.slice(0, PAGE_SIZE) : results; + const hasNextPage = results.length > limit; + const items = hasNextPage ? results.slice(0, limit) : results; const nextCursor = hasNextPage ? items[items.length - 1].score - 1 : null; return { diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 22a8f55..ff83d8c 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -119,8 +119,26 @@ export const adminRouter = router({ .input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); + + // If this post is itself a repost, deleting it must decrement the + // original post's repostCount — the same denormalized counter + // reposts.ts's user-facing delete keeps in sync (see Post model comment). + const post = await ctx.prisma.post.findUnique({ + where: { id: input.postId }, + select: { repost: { select: { originalPostId: true } } }, + }); + if (!post) throw new TRPCError({ code: "NOT_FOUND" }); + await ctx.prisma.$transaction([ ctx.prisma.post.delete({ where: { id: input.postId } }), + ...(post.repost + ? [ + ctx.prisma.post.update({ + where: { id: post.repost.originalPostId }, + data: { repostCount: { decrement: 1 } }, + }), + ] + : []), ctx.prisma.moderationLog.create({ data: { action: "DELETE_POST", @@ -318,9 +336,25 @@ export const adminRouter = router({ .input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() })) .mutation(async ({ ctx, input }) => { await assertAdmin(ctx.prisma, ctx.userId); + + // Same repostCount fix as deletePost above. + const post = await ctx.prisma.post.findUnique({ + where: { id: input.postId }, + select: { repost: { select: { originalPostId: true } } }, + }); + if (!post) throw new TRPCError({ code: "NOT_FOUND" }); + // Delete the post (cascades to reports, comments, reactions via Prisma schema) await ctx.prisma.$transaction([ ctx.prisma.post.delete({ where: { id: input.postId } }), + ...(post.repost + ? [ + ctx.prisma.post.update({ + where: { id: post.repost.originalPostId }, + data: { repostCount: { decrement: 1 } }, + }), + ] + : []), ctx.prisma.moderationLog.create({ data: { action: "DELETE_POST", diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts index a5afcc1..3668a9b 100644 --- a/src/trpc/routers/feed.ts +++ b/src/trpc/routers/feed.ts @@ -59,7 +59,7 @@ export const feedRouter = router({ let nextCursor: number | null; let usePostgresFallback = false; try { - const result = await getFeedPage(redis, input.petId, input.cursor ?? null); + const result = await getFeedPage(redis, input.petId, input.cursor ?? null, input.limit); postIds = result.postIds; nextCursor = result.nextCursor; } catch { @@ -69,16 +69,23 @@ export const feedRouter = router({ } if (usePostgresFallback) { - const follows = await ctx.prisma.follow.findMany({ - where: { followerPetId: input.petId }, - select: { followeePetId: true }, - }); + const [follows, blockedEntries] = await Promise.all([ + ctx.prisma.follow.findMany({ + where: { followerPetId: input.petId }, + select: { followeePetId: true }, + }), + ctx.prisma.block.findMany({ + where: { blockerPetId: input.petId }, + select: { blockedPetId: true }, + }), + ]); const petIds = [input.petId, ...follows.map((f) => f.followeePetId)]; + const blockedPetIds = blockedEntries.map((b) => b.blockedPetId); const cursorDate = input.cursor ? new Date(input.cursor) : undefined; const rawPosts = await ctx.prisma.post.findMany({ where: { - petId: { in: petIds }, + petId: { in: petIds, notIn: blockedPetIds }, ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}), }, orderBy: { createdAt: "desc" }, diff --git a/src/trpc/routers/milestones.ts b/src/trpc/routers/milestones.ts index 0f45dae..b0da740 100644 --- a/src/trpc/routers/milestones.ts +++ b/src/trpc/routers/milestones.ts @@ -1,19 +1,20 @@ import "server-only"; import { z } from "zod"; -import { MilestoneType } from "@prisma/client"; import { router, protectedProcedure } from "@/trpc/init"; import { redis } from "@/lib/redis"; import { fanOutPost } from "@/lib/feed-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; +import { USER_INITIATED_MILESTONE_TYPES } from "@/lib/milestone-meta"; /** * Milestones tRPC router — structured milestone posts (MILE-01, MILE-02). * * Security (T-02-50 to T-02-53): * - create asserts pet ownership before any write (T-02-50) - * - milestoneType constrained to z.nativeEnum(MilestoneType) — rejects forged types (T-02-51) + * - milestoneType constrained to the 5 user-initiated types (T-02-51) — follower-threshold + * types (FOLLOWERS_500..FOLLOWERS_1M) are auto-triggered only (D-10, follows.ts) and are + * rejected here, not just hidden from the MilestoneForm picker (T-02-53) * - optional imageKey validated with .startsWith('posts/') (T-02-52) - * - follower-threshold types excluded from MilestoneForm picker (T-02-53, D-10) */ export const milestonesRouter = router({ @@ -25,7 +26,7 @@ export const milestonesRouter = router({ .input( z.object({ petId: z.string().min(1), - milestoneType: z.nativeEnum(MilestoneType), + milestoneType: z.enum(USER_INITIATED_MILESTONE_TYPES), notes: z.string().max(500).optional(), caption: z.string().max(500).optional(), imageKey: z.string().startsWith("posts/").optional(),