Merge pull request #1104 from mdisec/fix-private-prompt-history
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user