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:
1 parent
03abe472da
commit
bf49024ace
9 files changed
+146
-29
No files matched your search
@@ -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" });
|
||||
|
||||
|
||||
@@ -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"]] });
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user