fix: remaining MEDIUM/LOW findings from 2026-07-21 audit rerun

- consume-invite: check-then-act race on single-use invite redemption —
  two concurrent redemptions could both pass the pre-check. Now guarded
  with an atomic updateMany(where: {code, usedById: null, revoked: false}).
- schema.prisma: added missing index on Report.targetPostId (used by
  admin.ts's groupBy/filter).
- RepostCard.tsx: reposter link used a raw <a> (full page reload) instead
  of next/link; the original poster's header had no link at all.
- Sidebar.tsx/MobileNav.tsx: removed the duplicated "set active pet on
  first load" effect — ActivePetInitializer already does this centrally
  and is mounted alongside both in the app layout.
- prisma.ts: removed the dead Vercel/Prisma Accelerate code path
  (PRISMA_ACCELERATE_URL is never set — this is a self-hosted Docker
  deployment) and the now-unused @prisma/extension-accelerate dependency.
- .env.example: corrected the NEXT_PUBLIC_APP_URL comment (Mux cors_origin
  is intentionally "*", not wired to it) and documented CRON_SECRET,
  which was missing despite both cron routes requiring it.
- reactions.ts: removed hasReacted/getCount — dead code with no frontend
  callers; getCount also duplicated the denormalized Post.reactionCount.
This commit is contained in:
admin committed 2026-07-22 19:34:18 +02:00
1 parent bf49024ace
commit 4fcde7eb2a
11 files changed
+358 -749

No files matched your search

+10 -1
View File
@@ -32,9 +32,18 @@ MUX_TOKEN_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Mux Dashboard → Webhooks → Add endpoint → copy Signing Secret
# Endpoint must point at https://<your-domain>/api/webhooks/mux
MUX_WEBHOOK_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Your public app URL (used as CORS origin for Mux direct uploads)
# Your public app URL — currently informational only. Mux's cors_origin
# (videos.ts) is intentionally "*" for now; restrict it to this value if
# you lock down direct-upload origins later.
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# ── Cron ──────────────────────────────────────────────────────
# Bearer token required by /api/cron/trim-feeds and /api/cron/anniversaries.
# vercel.json's crons array is inert on this self-hosted deployment — an
# external trigger (e.g. a NAS-side crontab) must call these routes with
# this token in the Authorization header.
CRON_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# ── Admin Panel ───────────────────────────────────────────────
# NEVER use NEXT_PUBLIC_ prefix — these MUST stay server-only!
# ADMIN_SECRET: random UUID path segment (/p/[secret]/) — generate with:
+325 -680
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -19,7 +19,6 @@
"@mux/mux-player-react": "^3.13.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@prisma/extension-accelerate": "^3.0.1",
"@supabase/supabase-js": "^2.107.0",
"@tanstack/react-query": "^5.101.0",
"@trpc/client": "^11.17.0",
+1
View File
@@ -296,6 +296,7 @@ model Report {
createdAt DateTime @default(now())
@@index([reporterPetId])
@@index([targetPostId])
}
// ─── Phase 3 Models ──────────────────────────────────────────────────────────
-17
View File
@@ -123,20 +123,3 @@ describe("reactions.toggle", () => {
).rejects.toThrow(TRPCError);
});
});
// ─── reactions.getCount ───────────────────────────────────────────────────────
describe("reactions.getCount", () => {
it("ENGM-01 — getCount returns the reaction count for a post", async () => {
const { caller, mockPrisma } = await createReactionsCaller();
mockPrisma.reaction.count.mockResolvedValue(7);
const count = await caller.reactions.getCount({ postId: "post-1" });
expect(mockPrisma.reaction.count).toHaveBeenCalledWith(
expect.objectContaining({ where: { postId: "post-1" } })
);
expect(count).toBe(7);
});
});
+6 -2
View File
@@ -39,10 +39,14 @@ async function consumeInviteCode(userId: string, code: string) {
if (!invite || invite.usedById || invite.revoked) return;
await prisma.$transaction(async (tx) => {
await tx.inviteCode.update({
where: { code },
// Guard the update with usedById: null so a concurrent redemption of the
// same single-use code can't both pass the pre-check above and both
// succeed — only the transaction that wins the race actually flips it.
const claimed = await tx.inviteCode.updateMany({
where: { code, usedById: null, revoked: false },
data: { usedById: userId, usedAt: new Date() },
});
if (claimed.count === 0) return;
await tx.owner.upsert({
where: { id: userId },
+8 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { PawPrint } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
@@ -81,16 +82,19 @@ function RepostCardInner({
<PawPrint className="h-3.5 w-3.5 text-orange-400" aria-hidden="true" />
<span className="text-xs text-muted-foreground">
Pawed-back by{" "}
<a href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
<Link href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
@{reposterName}
</a>
</Link>
</span>
</div>
<Separator className="mx-4" />
{/* Original pet header */}
<div className="flex items-center gap-3 px-4 py-3">
<Link
href={`/pets/${origPet.id}`}
className="flex items-center gap-3 px-4 py-3 hover:opacity-80 transition-opacity"
>
<Avatar className="w-8 h-8">
{origAvatarUrl && (
<AvatarImage src={origAvatarUrl} alt={origPet.name} />
@@ -102,7 +106,7 @@ function RepostCardInner({
<p className="text-base font-semibold text-foreground leading-tight truncate">
{origPet.name}
</p>
</div>
</Link>
{/* Original images */}
{origImages.length > 0 && (
+3 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -61,11 +61,8 @@ export function MobileNav({ pets }: MobileNavProps) {
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
useEffect(() => {
if (!activePetId && pets.length > 0) {
setActivePet(pets[0].id);
}
}, [activePetId, pets, setActivePet]);
// First-load fallback is handled once, centrally, by ActivePetInitializer
// (mounted alongside Sidebar/MobileNav in the app layout) — don't duplicate it here.
// D-05: Home → /feed; also highlight when on / (which redirects to /feed)
const isHomePage = pathname === "/feed" || pathname === "/";
+3 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -79,11 +79,8 @@ export function Sidebar({ pets }: SidebarProps) {
const activePet = pets.find((p) => p.id === activePetId) ?? pets[0];
const [postTypeSheetOpen, setPostTypeSheetOpen] = useState(false);
useEffect(() => {
if (!activePetId && pets.length > 0) {
setActivePet(pets[0].id);
}
}, [activePetId, pets, setActivePet]);
// First-load fallback is handled once, centrally, by ActivePetInitializer
// (mounted alongside Sidebar/MobileNav in the app layout) — don't duplicate it here.
return (
<nav className="hidden lg:flex flex-col w-[240px] h-screen sticky top-0 bg-card border-r border shrink-0 overflow-y-auto">
+2 -16
View File
@@ -1,17 +1,11 @@
import "server-only";
import { PrismaClient } from "@prisma/client";
import { withAccelerate } from "@prisma/extension-accelerate";
import { PrismaPg } from "@prisma/adapter-pg";
import { Pool } from "pg";
/**
* Prisma 7 singleton for Next.js / serverless environments.
*
* Prisma 7 requires either an `adapter` (for direct DB connections)
* or an `accelerateUrl` (for Prisma Accelerate on Vercel).
*
* Local dev: uses @prisma/adapter-pg with a pg Pool (direct Supabase connection).
* Vercel/Accelerate: set PRISMA_ACCELERATE_URL and this will use withAccelerate().
* Prisma 7 singleton — self-hosted Docker deployment, not Vercel.
* Uses @prisma/adapter-pg with a pg Pool for a direct Supabase connection.
*/
const globalForPrisma = globalThis as unknown as {
@@ -19,15 +13,8 @@ const globalForPrisma = globalThis as unknown as {
};
function createPrismaClient(): PrismaClient {
const accelerateUrl = process.env.PRISMA_ACCELERATE_URL;
const databaseUrl = process.env.DATABASE_URL;
if (accelerateUrl) {
// Prisma Accelerate path (Vercel production / staging)
const client = new PrismaClient({ accelerateUrl });
return client.$extends(withAccelerate()) as unknown as PrismaClient;
}
if (!databaseUrl) {
throw new Error(
"DATABASE_URL environment variable is not set. " +
@@ -35,7 +22,6 @@ function createPrismaClient(): PrismaClient {
);
}
// Local dev / direct Supabase connection path
const adapter = new PrismaPg(new Pool({ connectionString: databaseUrl }));
return new PrismaClient({ adapter });
}
-16
View File
@@ -50,20 +50,4 @@ export const reactionsRouter = router({
return { reacted: true };
}),
hasReacted: protectedProcedure
.input(z.object({ postId: z.string().min(1), petId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<boolean> => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const existing = await ctx.prisma.reaction.findUnique({
where: { postId_petId: { postId: input.postId, petId: input.petId } },
});
return existing !== null;
}),
getCount: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<number> => {
return ctx.prisma.reaction.count({ where: { postId: input.postId } });
}),
});