fix: 服务端验证强化 — 房间ID/坐标/swipe/盲盒竞态/空格
- #15: 房间 ID 扩展为 6 位字母数字,createRoom 用 P2002 重试替代 find-then-create - #16: 盲盒编辑/删除改用 updateMany/deleteMany 原子操作,防止 TOCTOU - #17: lat/lng 用 Number.isFinite + 范围校验 (-90~90, -180~180) - #18: swipe action 必须为 'like' 或 'pass' - #19: user PUT 的 JSON.parse(preferences) 加 try/catch - #26: requireString 拒绝纯空格字符串
This commit is contained in:
@@ -55,17 +55,14 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
requireString(ideaId, "ideaId");
|
requireString(ideaId, "ideaId");
|
||||||
const trimmedContent = validateIdeaContent(content);
|
const trimmedContent = validateIdeaContent(content);
|
||||||
|
|
||||||
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
|
const { count } = await prisma.blindBoxIdea.updateMany({
|
||||||
if (!idea) throw new ApiError("想法不存在", 404);
|
where: { id: ideaId, userId, status: "in_pool" },
|
||||||
if (idea.userId !== userId) throw new ApiError("只能编辑自己的想法", 403);
|
|
||||||
if (idea.status !== "in_pool") throw new ApiError("已抽中的想法不能编辑");
|
|
||||||
|
|
||||||
const updated = await prisma.blindBoxIdea.update({
|
|
||||||
where: { id: ideaId },
|
|
||||||
data: { content: trimmedContent },
|
data: { content: trimmedContent },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ id: updated.id, content: updated.content });
|
if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404);
|
||||||
|
|
||||||
|
return NextResponse.json({ id: ideaId, content: trimmedContent });
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DELETE = apiHandler(async (req) => {
|
export const DELETE = apiHandler(async (req) => {
|
||||||
@@ -74,12 +71,11 @@ export const DELETE = apiHandler(async (req) => {
|
|||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
requireString(ideaId, "ideaId");
|
requireString(ideaId, "ideaId");
|
||||||
|
|
||||||
const idea = await prisma.blindBoxIdea.findUnique({ where: { id: ideaId } });
|
const { count } = await prisma.blindBoxIdea.deleteMany({
|
||||||
if (!idea) throw new ApiError("想法不存在", 404);
|
where: { id: ideaId, userId, status: "in_pool" },
|
||||||
if (idea.userId !== userId) throw new ApiError("只能删除自己的想法", 403);
|
});
|
||||||
if (idea.status !== "in_pool") throw new ApiError("已抽中的想法不能删除");
|
|
||||||
|
|
||||||
await prisma.blindBoxIdea.delete({ where: { id: ideaId } });
|
if (count === 0) throw new ApiError("想法不存在、已被抽中或无权删除", 404);
|
||||||
|
|
||||||
return NextResponse.json({ deleted: true });
|
return NextResponse.json({ deleted: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const POST = apiHandler(async (req, { params }) => {
|
|||||||
if (restaurantId == null || !action) {
|
if (restaurantId == null || !action) {
|
||||||
throw new ApiError("restaurantId and action are required");
|
throw new ApiError("restaurantId and action are required");
|
||||||
}
|
}
|
||||||
|
if (action !== "like" && action !== "pass") {
|
||||||
|
throw new ApiError("action must be 'like' or 'pass'");
|
||||||
|
}
|
||||||
|
|
||||||
const rid = String(restaurantId);
|
const rid = String(restaurantId);
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,10 @@ export const POST = apiHandler(async (req) => {
|
|||||||
|
|
||||||
const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat");
|
const sceneConfig = getSceneConfig(scene === "drink" ? "drink" : "eat");
|
||||||
|
|
||||||
if (!lat || !lng) {
|
const numLat = Number(lat);
|
||||||
|
const numLng = Number(lng);
|
||||||
|
if (!Number.isFinite(numLat) || !Number.isFinite(numLng) ||
|
||||||
|
numLat < -90 || numLat > 90 || numLng < -180 || numLng > 180) {
|
||||||
throw new ApiError("无法获取位置信息,请允许定位权限后重试");
|
throw new ApiError("无法获取位置信息,请允许定位权限后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +128,7 @@ export const POST = apiHandler(async (req) => {
|
|||||||
|
|
||||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||||
url.searchParams.set("key", apiKey);
|
url.searchParams.set("key", apiKey);
|
||||||
url.searchParams.set("location", `${lng},${lat}`);
|
url.searchParams.set("location", `${numLng},${numLat}`);
|
||||||
url.searchParams.set("radius", String(radius));
|
url.searchParams.set("radius", String(radius));
|
||||||
url.searchParams.set("types", sceneConfig.poiTypes);
|
url.searchParams.set("types", sceneConfig.poiTypes);
|
||||||
url.searchParams.set("show_fields", "business,photos");
|
url.searchParams.set("show_fields", "business,photos");
|
||||||
|
|||||||
@@ -73,12 +73,15 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let prefs = {};
|
||||||
|
try { prefs = JSON.parse(user.preferences); } catch { /* fallback */ }
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
preferences: JSON.parse(user.preferences),
|
preferences: prefs,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
|||||||
+19
-14
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { Restaurant, SceneType } from "@/types";
|
import { Restaurant, SceneType } from "@/types";
|
||||||
|
|
||||||
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const ROOM_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -15,8 +16,14 @@ export interface RoomData {
|
|||||||
scene: SceneType;
|
scene: SceneType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROOM_ID_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
function generateRoomId(): string {
|
function generateRoomId(): string {
|
||||||
return String(Math.floor(1000 + Math.random() * 9000));
|
let id = "";
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
id += ROOM_ID_CHARS[Math.floor(Math.random() * ROOM_ID_CHARS.length)];
|
||||||
|
}
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalize(raw: Partial<RoomData>): RoomData {
|
function normalize(raw: Partial<RoomData>): RoomData {
|
||||||
@@ -69,26 +76,24 @@ export async function createRoom(restaurants: Restaurant[], creatorId: string, s
|
|||||||
};
|
};
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
||||||
let roomId: string;
|
const payload = JSON.stringify(data);
|
||||||
let attempts = 0;
|
|
||||||
|
|
||||||
while (attempts < 20) {
|
for (let attempts = 0; attempts < 20; attempts++) {
|
||||||
roomId = generateRoomId();
|
const roomId = generateRoomId();
|
||||||
const existing = await prisma.room.findUnique({ where: { id: roomId } });
|
try {
|
||||||
if (!existing) {
|
|
||||||
await prisma.room.create({
|
await prisma.room.create({
|
||||||
data: { id: roomId, data: JSON.stringify(data), expiresAt },
|
data: { id: roomId, data: payload, expiresAt },
|
||||||
});
|
});
|
||||||
return roomId;
|
return roomId;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
attempts++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
roomId = generateRoomId() + String(Date.now()).slice(-2);
|
throw new Error("无法生成唯一房间号,请稍后重试");
|
||||||
await prisma.room.create({
|
|
||||||
data: { id: roomId, data: JSON.stringify(data), expiresAt },
|
|
||||||
});
|
|
||||||
return roomId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRoomData(
|
export async function getRoomData(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function validateRoomName(raw: unknown, fallback = "我们的周末"): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function requireString(value: unknown, fieldName: string): string {
|
export function requireString(value: unknown, fieldName: string): string {
|
||||||
if (!value || typeof value !== "string") {
|
if (!value || typeof value !== "string" || !value.trim()) {
|
||||||
throw new ApiError(`${fieldName}不能为空`);
|
throw new ApiError(`${fieldName}不能为空`);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
Reference in New Issue
Block a user