Browse Source

feat(comments): enforce guest email anonymity rules

Add guest email validation with anonymous-mode exceptions and persist guestEmail/guestIsAnonymous in comment creation. Update comment form to collect anonymous and email fields for guests and reset them after successful submit.

Made-with: Cursor
main
npmrun 3 weeks ago
parent
commit
b7c87d2d95
  1. 24
      app/components/PostComments.vue
  2. 4
      server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts
  3. 4
      server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts
  4. 11
      server/service/post-comments/index.ts
  5. 19
      server/utils/post-comment-guest.test.ts
  6. 14
      server/utils/post-comment-guest.ts

24
app/components/PostComments.vue

@ -81,6 +81,8 @@ function flattenTree(nodes: CommentNode[], depth = 0): Array<{ node: CommentNode
const replyToId = ref<number | null>(null) const replyToId = ref<number | null>(null)
const draftBody = ref('') const draftBody = ref('')
const guestName = ref('') const guestName = ref('')
const guestEmail = ref('')
const guestIsAnonymous = ref(false)
const submitting = ref(false) const submitting = ref(false)
function authorLine(node: CommentNode): string { function authorLine(node: CommentNode): string {
@ -129,6 +131,10 @@ async function submitComment() {
toast.add({ title: '请填写昵称', color: 'error' }) toast.add({ title: '请填写昵称', color: 'error' })
return return
} }
if (!guestIsAnonymous.value && !guestEmail.value.trim()) {
toast.add({ title: '请填写邮箱', color: 'error' })
return
}
} }
submitting.value = true submitting.value = true
@ -136,7 +142,13 @@ async function submitComment() {
const payload = { const payload = {
parentId: replyToId.value, parentId: replyToId.value,
body: draftBody.value, body: draftBody.value,
...(loggedIn.value ? {} : { guestDisplayName: guestName.value.trim() }), ...(loggedIn.value
? {}
: {
guestDisplayName: guestName.value.trim(),
guestEmail: guestEmail.value.trim(),
guestIsAnonymous: guestIsAnonymous.value,
}),
} }
await fetchData<{ id: number }>(`${base}/comments`, { await fetchData<{ id: number }>(`${base}/comments`, {
@ -146,6 +158,8 @@ async function submitComment() {
draftBody.value = '' draftBody.value = ''
guestName.value = '' guestName.value = ''
guestEmail.value = ''
guestIsAnonymous.value = false
replyToId.value = null replyToId.value = null
await refreshComments() await refreshComments()
toast.add({ title: '评论已发送', color: 'success' }) toast.add({ title: '评论已发送', color: 'success' })
@ -223,6 +237,14 @@ async function submitComment() {
</template> </template>
<template v-else> <template v-else>
<UInput v-model="guestName" placeholder="你的昵称" class="w-full max-w-md" /> <UInput v-model="guestName" placeholder="你的昵称" class="w-full max-w-md" />
<UInput
v-model="guestEmail"
type="email"
:required="!guestIsAnonymous"
:placeholder="guestIsAnonymous ? '你的邮箱(匿名时可选)' : '你的邮箱(必填)'"
class="w-full max-w-md"
/>
<UCheckbox v-model="guestIsAnonymous" label="匿名评论" />
<UTextarea v-model="draftBody" placeholder="写下你的评论…" :rows="4" class="w-full" /> <UTextarea v-model="draftBody" placeholder="写下你的评论…" :rows="4" class="w-full" />
<UButton :loading="submitting" label="发表" @click="submitComment" /> <UButton :loading="submitting" label="发表" @click="submitComment" />
</template> </template>

4
server/api/public/profile/[publicSlug]/posts/[postSlug]/comments.post.ts

@ -26,6 +26,8 @@ export default defineEventHandler(async (event) => {
const body = await readBody<{ const body = await readBody<{
parentId?: number | null; parentId?: number | null;
guestDisplayName?: string; guestDisplayName?: string;
guestEmail?: string;
guestIsAnonymous?: boolean;
body: unknown; body: unknown;
}>(event); }>(event);
@ -48,6 +50,8 @@ export default defineEventHandler(async (event) => {
parentId, parentId,
viewer, viewer,
guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined,
guestEmail: typeof body.guestEmail === "string" ? body.guestEmail : undefined,
guestIsAnonymous: body.guestIsAnonymous === true,
body: body.body, body: body.body,
}); });

4
server/api/public/unlisted/[publicSlug]/[shareToken]/comments.post.ts

@ -26,6 +26,8 @@ export default defineEventHandler(async (event) => {
const body = await readBody<{ const body = await readBody<{
parentId?: number | null; parentId?: number | null;
guestDisplayName?: string; guestDisplayName?: string;
guestEmail?: string;
guestIsAnonymous?: boolean;
body: unknown; body: unknown;
}>(event); }>(event);
@ -48,6 +50,8 @@ export default defineEventHandler(async (event) => {
parentId, parentId,
viewer, viewer,
guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined, guestDisplayName: typeof body.guestDisplayName === "string" ? body.guestDisplayName : undefined,
guestEmail: typeof body.guestEmail === "string" ? body.guestEmail : undefined,
guestIsAnonymous: body.guestIsAnonymous === true,
body: body.body, body: body.body,
}); });

11
server/service/post-comments/index.ts

@ -7,6 +7,7 @@ import {
GuestCommentValidationError, GuestCommentValidationError,
normalizeGuestDisplayName, normalizeGuestDisplayName,
validateGuestCommentBody, validateGuestCommentBody,
validateGuestCommentEmail,
} from "#server/utils/post-comment-guest"; } from "#server/utils/post-comment-guest";
import type { MinimalUser } from "#server/service/auth"; import type { MinimalUser } from "#server/service/auth";
@ -128,6 +129,8 @@ export async function createComment(input: {
parentId: number | null; parentId: number | null;
viewer: MinimalUser | null; viewer: MinimalUser | null;
guestDisplayName?: string; guestDisplayName?: string;
guestEmail?: string;
guestIsAnonymous?: boolean;
body: string; body: string;
}): Promise<number> { }): Promise<number> {
if (input.parentId != null) { if (input.parentId != null) {
@ -144,6 +147,8 @@ export async function createComment(input: {
let kind: string; let kind: string;
let authorUserId: number | null; let authorUserId: number | null;
let guestDisplayName: string | null; let guestDisplayName: string | null;
let guestEmail: string | null;
let guestIsAnonymous: boolean;
let body: string; let body: string;
if (input.viewer != null) { if (input.viewer != null) {
@ -157,10 +162,14 @@ export async function createComment(input: {
kind = "user"; kind = "user";
authorUserId = input.viewer.id; authorUserId = input.viewer.id;
guestDisplayName = null; guestDisplayName = null;
guestEmail = null;
guestIsAnonymous = false;
} else { } else {
try { try {
guestIsAnonymous = input.guestIsAnonymous === true;
guestDisplayName = normalizeGuestDisplayName(input.guestDisplayName ?? ""); guestDisplayName = normalizeGuestDisplayName(input.guestDisplayName ?? "");
body = validateGuestCommentBody(input.body); body = validateGuestCommentBody(input.body);
guestEmail = validateGuestCommentEmail(input.guestEmail, guestIsAnonymous);
} catch (e) { } catch (e) {
if (e instanceof GuestCommentValidationError) { if (e instanceof GuestCommentValidationError) {
throw createError({ statusCode: 400, statusMessage: e.message }); throw createError({ statusCode: 400, statusMessage: e.message });
@ -178,6 +187,8 @@ export async function createComment(input: {
parentId: input.parentId, parentId: input.parentId,
authorUserId, authorUserId,
guestDisplayName, guestDisplayName,
guestEmail,
guestIsAnonymous,
body, body,
kind, kind,
}); });

19
server/utils/post-comment-guest.test.ts

@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { import {
GuestCommentValidationError, GuestCommentValidationError,
normalizeGuestDisplayName, normalizeGuestDisplayName,
validateGuestCommentEmail,
validateGuestCommentBody, validateGuestCommentBody,
} from "./post-comment-guest"; } from "./post-comment-guest";
@ -36,3 +37,21 @@ describe("validateGuestCommentBody", () => {
expect(() => validateGuestCommentBody("z".repeat(501))).toThrow(GuestCommentValidationError); expect(() => validateGuestCommentBody("z".repeat(501))).toThrow(GuestCommentValidationError);
}); });
}); });
describe("validateGuestCommentEmail", () => {
test("accepts empty email when anonymous", () => {
expect(validateGuestCommentEmail("", true)).toBeNull();
});
test("requires email when not anonymous", () => {
expect(() => validateGuestCommentEmail("", false)).toThrow(GuestCommentValidationError);
});
test("rejects invalid email when not anonymous", () => {
expect(() => validateGuestCommentEmail("invalid-email", false)).toThrow(GuestCommentValidationError);
});
test("accepts valid email when not anonymous", () => {
expect(validateGuestCommentEmail(" guest@example.com ", false)).toBe("guest@example.com");
});
});

14
server/utils/post-comment-guest.ts

@ -36,3 +36,17 @@ export function validateGuestCommentBody(raw: string): string {
} }
return t; return t;
} }
export function validateGuestCommentEmail(raw: string | undefined, guestIsAnonymous: boolean): string | null {
const t = (raw ?? "").trim();
if (guestIsAnonymous && !t) {
return null;
}
if (!t) {
throw new GuestCommentValidationError("请填写邮箱");
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t)) {
throw new GuestCommentValidationError("邮箱格式不合法");
}
return t;
}

Loading…
Cancel
Save