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

+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]);