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:
1 parent
bf49024ace
commit
4fcde7eb2a
11 files changed
+358
-749
No files matched your search
+10
-1
@@ -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:
|
||||
|
||||
Generated
+325
-680
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -296,6 +296,7 @@ model Report {
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([reporterPetId])
|
||||
@@index([targetPostId])
|
||||
}
|
||||
|
||||
// ─── Phase 3 Models ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 === "/";
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user