Merge pull request #1104 from mdisec/fix-private-prompt-history

This commit is contained in:
Fatih Kadir Akın
2026-03-29 06:29:24 +03:00
committed by GitHub
9 changed files with 223 additions and 13 deletions
+16
View File
@@ -29,6 +29,10 @@ describe("GET /api/prompts/[id]/versions", () => {
});
it("should return empty array for prompt with no versions", async () => {
vi.mocked(db.prompt.findUnique).mockResolvedValue({
isPrivate: false,
authorId: "user1",
} as never);
vi.mocked(db.promptVersion.findMany).mockResolvedValue([]);
const request = new Request("http://localhost:3000/api/prompts/123/versions");
@@ -42,6 +46,10 @@ describe("GET /api/prompts/[id]/versions", () => {
});
it("should return versions ordered by version desc", async () => {
vi.mocked(db.prompt.findUnique).mockResolvedValue({
isPrivate: false,
authorId: "user1",
} as never);
vi.mocked(db.promptVersion.findMany).mockResolvedValue([
{
id: "v3",
@@ -83,6 +91,10 @@ describe("GET /api/prompts/[id]/versions", () => {
});
it("should include author info in response", async () => {
vi.mocked(db.prompt.findUnique).mockResolvedValue({
isPrivate: false,
authorId: "user1",
} as never);
vi.mocked(db.promptVersion.findMany).mockResolvedValue([
{
id: "v1",
@@ -106,6 +118,10 @@ describe("GET /api/prompts/[id]/versions", () => {
});
it("should call findMany with correct parameters", async () => {
vi.mocked(db.prompt.findUnique).mockResolvedValue({
isPrivate: false,
authorId: "user1",
} as never);
vi.mocked(db.promptVersion.findMany).mockResolvedValue([]);
const request = new Request("http://localhost:3000/api/prompts/123/versions");
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { canViewPrompt, checkPromptAccess } from "@/lib/prompt-access";
import { auth } from "@/lib/auth";
import type { Session } from "next-auth";
vi.mock("@/lib/auth", () => ({
auth: vi.fn(),
}));
describe("canViewPrompt", () => {
it("should return false for null prompt", () => {
expect(canViewPrompt(null, null)).toBe(false);
});
it("should return true for public prompt with no session", () => {
expect(
canViewPrompt({ isPrivate: false, authorId: "user1" }, null)
).toBe(true);
});
it("should return true for public prompt with any session", () => {
const session = { user: { id: "other" } } as Session;
expect(
canViewPrompt({ isPrivate: false, authorId: "user1" }, session)
).toBe(true);
});
it("should return false for private prompt with no session", () => {
expect(
canViewPrompt({ isPrivate: true, authorId: "user1" }, null)
).toBe(false);
});
it("should return false for private prompt when user is not owner", () => {
const session = { user: { id: "other", role: "USER" } } as Session;
expect(
canViewPrompt({ isPrivate: true, authorId: "user1" }, session)
).toBe(false);
});
it("should return true for private prompt when user is the owner", () => {
const session = { user: { id: "user1", role: "USER" } } as Session;
expect(
canViewPrompt({ isPrivate: true, authorId: "user1" }, session)
).toBe(true);
});
it("should return true for private prompt when user is admin", () => {
const session = { user: { id: "admin1", role: "ADMIN" } } as Session;
expect(
canViewPrompt({ isPrivate: true, authorId: "user1" }, session)
).toBe(true);
});
it("should return false for private prompt with session missing user", () => {
const session = {} as Session;
expect(
canViewPrompt({ isPrivate: true, authorId: "user1" }, session)
).toBe(false);
});
});
describe("checkPromptAccess", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return 404 for null prompt", async () => {
const result = await checkPromptAccess(null);
expect(result).not.toBeNull();
expect(result!.status).toBe(404);
const data = await result!.json();
expect(data.error).toBe("not_found");
});
it("should return null for public prompt (no auth call)", async () => {
const result = await checkPromptAccess({ isPrivate: false, authorId: "user1" });
expect(result).toBeNull();
expect(auth).not.toHaveBeenCalled();
});
it("should return 404 for private prompt with no session", async () => {
vi.mocked(auth).mockResolvedValue(null);
const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" });
expect(result).not.toBeNull();
expect(result!.status).toBe(404);
expect(auth).toHaveBeenCalledOnce();
});
it("should return 404 for private prompt when user is not owner", async () => {
vi.mocked(auth).mockResolvedValue({ user: { id: "other", role: "USER" } } as never);
const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" });
expect(result).not.toBeNull();
expect(result!.status).toBe(404);
});
it("should return null for private prompt when user is the owner", async () => {
vi.mocked(auth).mockResolvedValue({ user: { id: "user1", role: "USER" } } as never);
const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" });
expect(result).toBeNull();
});
it("should return null for private prompt when user is admin", async () => {
vi.mocked(auth).mockResolvedValue({ user: { id: "admin1", role: "ADMIN" } } as never);
const result = await checkPromptAccess({ isPrivate: true, authorId: "user1" });
expect(result).toBeNull();
});
});
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { checkPromptAccess } from "@/lib/prompt-access";
const updateChangeRequestSchema = z.object({
status: z.enum(["APPROVED", "REJECTED", "PENDING"]),
@@ -196,7 +197,8 @@ export async function GET(
select: {
id: true,
title: true,
content: true,
isPrivate: true,
authorId: true,
},
},
},
@@ -209,6 +211,9 @@ export async function GET(
);
}
const denied = await checkPromptAccess(changeRequest.prompt);
if (denied) return denied;
return NextResponse.json(changeRequest);
} catch (error) {
console.error("Get change request error:", error);
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { checkPromptAccess } from "@/lib/prompt-access";
const createChangeRequestSchema = z.object({
proposedContent: z.string().min(1),
@@ -94,6 +95,14 @@ export async function GET(
try {
const { id: promptId } = await params;
const prompt = await db.prompt.findUnique({
where: { id: promptId },
select: { isPrivate: true, authorId: true },
});
const denied = await checkPromptAccess(prompt);
if (denied) return denied;
const changeRequests = await db.changeRequest.findMany({
where: { promptId },
orderBy: { createdAt: "desc" },
+4 -4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { z } from "zod";
import { checkPromptAccess } from "@/lib/prompt-access";
const addExampleSchema = z.object({
mediaUrl: z.string().url(),
@@ -16,12 +17,11 @@ export async function GET(
const prompt = await db.prompt.findUnique({
where: { id: promptId },
select: { id: true, type: true },
select: { id: true, type: true, isPrivate: true, authorId: true },
});
if (!prompt) {
return NextResponse.json({ error: "Prompt not found" }, { status: 404 });
}
const denied = await checkPromptAccess(prompt);
if (denied || !prompt) return denied!;
// Only allow examples for IMAGE and VIDEO prompts
if (prompt.type !== "IMAGE" && prompt.type !== "VIDEO") {
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { checkPromptAccess } from "@/lib/prompt-access";
const createVersionSchema = z.object({
content: z.string().min(1, "Content is required"),
@@ -116,6 +117,14 @@ export async function GET(
try {
const { id: promptId } = await params;
const prompt = await db.prompt.findUnique({
where: { id: promptId },
select: { isPrivate: true, authorId: true },
});
const denied = await checkPromptAccess(prompt);
if (denied) return denied;
const versions = await db.promptVersion.findMany({
where: { promptId },
orderBy: { version: "desc" },
+12 -6
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { checkPromptAccess } from "@/lib/prompt-access";
// POST - Upvote a prompt
export async function POST(
@@ -21,14 +22,11 @@ export async function POST(
// Check if prompt exists
const prompt = await db.prompt.findUnique({
where: { id: promptId },
select: { isPrivate: true, authorId: true },
});
if (!prompt) {
return NextResponse.json(
{ error: "not_found", message: "Prompt not found" },
{ status: 404 }
);
}
const denied = await checkPromptAccess(prompt);
if (denied) return denied;
// Check if already voted
const existing = await db.promptVote.findUnique({
@@ -86,6 +84,14 @@ export async function DELETE(
const { id: promptId } = await params;
const prompt = await db.prompt.findUnique({
where: { id: promptId },
select: { isPrivate: true, authorId: true },
});
const denied = await checkPromptAccess(prompt);
if (denied) return denied;
// Delete vote
await db.promptVote.deleteMany({
where: {
+10 -2
View File
@@ -8,6 +8,7 @@ import { AnimatedDate } from "@/components/ui/animated-date";
import { ShareDropdown } from "@/components/prompts/share-dropdown";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { canViewPrompt } from "@/lib/prompt-access";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -62,13 +63,20 @@ export async function generateMetadata({ params }: PromptPageProps): Promise<Met
const id = extractPromptId(idParam);
const prompt = await db.prompt.findUnique({
where: { id },
select: { title: true, description: true },
select: { title: true, description: true, isPrivate: true, authorId: true },
});
if (!prompt) {
return { title: "Prompt Not Found" };
}
if (prompt.isPrivate) {
const session = await auth();
if (!canViewPrompt(prompt, session)) {
return { title: "Prompt Not Found" };
}
}
return {
title: prompt.title,
description: prompt.description || `View the prompt: ${prompt.title}`,
@@ -224,7 +232,7 @@ export default async function PromptPage({ params }: PromptPageProps) {
}
// Check if user can view private prompt
if (prompt.isPrivate && prompt.authorId !== session?.user?.id) {
if (!canViewPrompt(prompt, session)) {
notFound();
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import type { Session } from "next-auth";
/**
* Check if a user can view a prompt that may be private.
* Returns true for public prompts, and for private prompts only if the user is the owner or an admin.
*/
export function canViewPrompt(
prompt: { isPrivate: boolean; authorId: string } | null,
session: Session | null
): boolean {
if (!prompt) return false;
if (!prompt.isPrivate) return true;
return prompt.authorId === session?.user?.id || session?.user?.role === "ADMIN";
}
/**
* API route guard for prompt privacy. Returns a 404 NextResponse if access is denied, or null if allowed.
* Returning 403 is also leak existence of the prompt itself !
* Calls auth() lazily — only when the prompt is private.
*
*
* Usage:
* const denied = await checkPromptAccess(prompt);
* if (denied) return denied;
*/
export async function checkPromptAccess(
prompt: { isPrivate: boolean; authorId: string } | null
): Promise<NextResponse | null> {
if (!prompt) {
return NextResponse.json(
{ error: "not_found", message: "Prompt not found" },
{ status: 404 }
);
}
if (!prompt.isPrivate) return null;
const session = await auth();
if (!canViewPrompt(prompt, session)) {
return NextResponse.json(
{ error: "not_found", message: "Prompt not found" },
{ status: 404 }
);
}
return null;
}