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
@@ -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]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user