diff --git a/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.get.ts b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.get.ts new file mode 100644 index 0000000..e6f8982 --- /dev/null +++ b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.get.ts @@ -0,0 +1,19 @@ +import { getPublicPostCommentContext } from "#server/service/posts"; +import { listCommentsPayload } from "#server/service/post-comments"; + +export default defineEventHandler(async (event) => { + const publicSlug = event.context.params?.publicSlug; + const postSlug = event.context.params?.postSlug; + if (!publicSlug || !postSlug || typeof publicSlug !== "string" || typeof postSlug !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + + const ctx = await getPublicPostCommentContext(publicSlug, postSlug); + if (!ctx) { + throw createError({ statusCode: 404, statusMessage: "未找到" }); + } + + const viewer = await event.context.auth.getCurrent(); + const data = await listCommentsPayload(ctx.id, ctx.userId, viewer?.id ?? null); + return R.success(data); +}); diff --git a/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts new file mode 100644 index 0000000..6390b6c --- /dev/null +++ b/server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts @@ -0,0 +1,55 @@ +import { getRequestIP } from "h3"; +import { getPublicPostCommentContext } from "#server/service/posts"; +import { createComment } from "#server/service/post-comments"; +import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; + +export default defineEventHandler(async (event) => { + const publicSlug = event.context.params?.publicSlug; + const postSlug = event.context.params?.postSlug; + if (!publicSlug || !postSlug || typeof publicSlug !== "string" || typeof postSlug !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + + const ctx = await getPublicPostCommentContext(publicSlug, postSlug); + if (!ctx) { + throw createError({ statusCode: 404, statusMessage: "未找到" }); + } + + const viewer = await event.context.auth.getCurrent(); + const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; + if (viewer == null) { + assertUnderRateLimit(`comment-post-guest:${ip}`, 20, 15 * 60 * 1000); + } else { + assertUnderRateLimit(`comment-post-user:${viewer.id}`, 60, 15 * 60 * 1000); + } + + const body = await readBody<{ + parentId?: number | null; + guestDisplayName?: string; + body: unknown; + }>(event); + + if (typeof body.body !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + + let parentId: number | null; + if (body.parentId === undefined || body.parentId === null) { + parentId = null; + } else if (typeof body.parentId === "number" && Number.isFinite(body.parentId)) { + parentId = body.parentId; + } else { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + + const newCommentId = await createComment({ + postId: ctx.id, + ownerUserId: ctx.userId, + parentId, + viewer, + guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, + body: body.body, + }); + + return R.success({ id: newCommentId }); +}); diff --git a/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.get.ts b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.get.ts new file mode 100644 index 0000000..ac6f2e4 --- /dev/null +++ b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.get.ts @@ -0,0 +1,19 @@ +import { getUnlistedPostCommentContext } from "#server/service/posts"; +import { listCommentsPayload } from "#server/service/post-comments"; + +export default defineEventHandler(async (event) => { + const publicSlug = event.context.params?.publicSlug; + const shareToken = event.context.params?.shareToken; + if (!publicSlug || !shareToken || typeof publicSlug !== "string" || typeof shareToken !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效链接" }); + } + + const ctx = await getUnlistedPostCommentContext(publicSlug, shareToken); + if (!ctx) { + throw createError({ statusCode: 404, statusMessage: "未找到" }); + } + + const viewer = await event.context.auth.getCurrent(); + const data = await listCommentsPayload(ctx.id, ctx.userId, viewer?.id ?? null); + return R.success(data); +}); diff --git a/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts new file mode 100644 index 0000000..00452f6 --- /dev/null +++ b/server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts @@ -0,0 +1,55 @@ +import { getRequestIP } from "h3"; +import { getUnlistedPostCommentContext } from "#server/service/posts"; +import { createComment } from "#server/service/post-comments"; +import { assertUnderRateLimit } from "#server/utils/simple-rate-limit"; + +export default defineEventHandler(async (event) => { + const publicSlug = event.context.params?.publicSlug; + const shareToken = event.context.params?.shareToken; + if (!publicSlug || !shareToken || typeof publicSlug !== "string" || typeof shareToken !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效链接" }); + } + + const ctx = await getUnlistedPostCommentContext(publicSlug, shareToken); + if (!ctx) { + throw createError({ statusCode: 404, statusMessage: "未找到" }); + } + + const viewer = await event.context.auth.getCurrent(); + const ip = getRequestIP(event, { xForwardedFor: true }) ?? "unknown"; + if (viewer == null) { + assertUnderRateLimit(`comment-post-guest:${ip}`, 20, 15 * 60 * 1000); + } else { + assertUnderRateLimit(`comment-post-user:${viewer.id}`, 60, 15 * 60 * 1000); + } + + const body = await readBody<{ + parentId?: number | null; + guestDisplayName?: string; + body: unknown; + }>(event); + + if (typeof body.body !== "string") { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + + let parentId: number | null; + if (body.parentId === undefined || body.parentId === null) { + parentId = null; + } else if (typeof body.parentId === "number" && Number.isFinite(body.parentId)) { + parentId = body.parentId; + } else { + throw createError({ statusCode: 400, statusMessage: "无效请求" }); + } + + const newCommentId = await createComment({ + postId: ctx.id, + ownerUserId: ctx.userId, + parentId, + viewer, + guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, + body: body.body, + }); + + return R.success({ id: newCommentId }); +}); diff --git a/server/utils/auth-api-routes.ts b/server/utils/auth-api-routes.ts index 9e2fccf..57c6b0a 100644 --- a/server/utils/auth-api-routes.ts +++ b/server/utils/auth-api-routes.ts @@ -9,13 +9,33 @@ const API_ALLOWLIST: RouteRule[] = [ { path: "/api/config/global", methods: ["GET"] }, ]; -/** 公开 API 只读,避免未认证写操作 */ +/** 允许访客发表评论的公开 POST(其余 /api/public/ 写操作仍拒绝) */ +function isPublicCommentPostPath(path: string) { + if (!path.endsWith("/comments")) { + return false; + } + if (/^\/api\/public\/profile\/[^/]+\/posts\/[^/]+\/comments$/.test(path)) { + return true; + } + if (/^\/api\/public\/unlisted\/[^/]+\/[^/]+\/comments$/.test(path)) { + return true; + } + return false; +} + +/** 公开 API 以只读为主;评论创建为例外,需配合服务端校验与限流 */ export function isPublicApiPath(path: string, method?: string) { if (!path.startsWith("/api/public/")) { return false; } const requestMethod = method?.toUpperCase() ?? "GET"; - return requestMethod === "GET"; + if (requestMethod === "GET") { + return true; + } + if (requestMethod === "POST" && isPublicCommentPostPath(path)) { + return true; + } + return false; } export function isAllowlistedApiPath(path: string, method?: string) {