fix: 6 HIGH-severity findings from 2026-07-21 audit rerun

- 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.
This commit is contained in:
admin committed 2026-07-22 19:24:21 +02:00
1 parent 03abe472da
commit bf49024ace
9 files changed
+146 -29

No files matched your search

+22
View File
@@ -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" });
+17 -10
View File
@@ -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"]] });
+35 -2
View File
@@ -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<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showReportSheet, setShowReportSheet] = useState(false);
const [showBlockDialog, setShowBlockDialog] = useState(false);
const [body, setBody] = useState("");
const [pendingBody, setPendingBody] = useState<string | null>(null);
const commentsEndRef = useRef<HTMLDivElement>(null);
@@ -223,9 +227,17 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD
</>
) : (
<>
<DropdownMenuItem className="cursor-pointer">{t("reportPost")}</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowReportSheet(true)}
className="cursor-pointer"
>
{t("reportPost")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer text-destructive focus:text-destructive">
<DropdownMenuItem
onClick={() => setShowBlockDialog(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
{t("blockPet", { name: pet.name })}
</DropdownMenuItem>
</>
@@ -508,6 +520,27 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ReportSheet — other's post */}
{activePetId && !isOwnPost && (
<ReportSheet
open={showReportSheet}
onOpenChange={setShowReportSheet}
reporterPetId={activePetId}
targetPostId={post.id}
/>
)}
{/* BlockDialog — other's post */}
{activePetId && !isOwnPost && (
<BlockDialog
open={showBlockDialog}
onOpenChange={setShowBlockDialog}
blockerPetId={activePetId}
blockedPetId={pet.id}
blockedPetName={pet.name}
/>
)}
</>
);
}
@@ -43,7 +43,9 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedType, setSelectedType] = useState<MilestoneType | null>(null);
const [selectedType, setSelectedType] = useState<
(typeof USER_INITIATED_MILESTONE_TYPES)[number] | null
>(null);
const [notes, setNotes] = useState("");
const [caption, setCaption] = useState("");
+12 -2
View File
@@ -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]);
+5 -4
View File
@@ -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 {
+34
View File
@@ -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",
+13 -6
View File
@@ -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" },
+5 -4
View File
@@ -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(),