feat : update

This commit is contained in:
2026-09-04 23:30:42 +07:00 Unverified
parent 890b88d4f1
commit b4f9fb0abb
12 changed files with 689 additions and 196 deletions
+9 -1
View File
@@ -34,6 +34,7 @@ import {
weaponSectionSchema,
} from "@/lib/guides/schemas";
import { processCatalogSync } from "@/lib/catalog/sync";
import { normalizeExtraRichText } from "@/lib/rich-text";
const DEFAULT_SECTIONS = [
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
@@ -208,7 +209,14 @@ export async function saveExtraSections(guideId: string, input: unknown) {
for (const [sortOrder, section] of data.sections.entries()) {
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
await tx.insert(extraSections).values({ sectionId: createdSection.id });
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({ ...row, percent: row.percent?.toString() ?? null, sectionId: createdSection.id, sortOrder: rowOrder })));
if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({
...row,
body: normalizeExtraRichText(row.body, row.highlights),
highlights: [],
percent: row.percent?.toString() ?? null,
sectionId: createdSection.id,
sortOrder: rowOrder,
})));
}
return updated;
});
+22
View File
@@ -158,6 +158,28 @@ describe("structured guide validation", () => {
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "red" }] })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "#FF0000" }, { text: "MASSIVE", color: "#00FF00" }] })).success).toBe(false);
});
it("sanitizes Extra rich text and allows formatting markup", () => {
const result = extraSectionSchema.safeParse({
expectedVersion: 1,
sections: [{
title: "Extra",
rows: [{
mediaId: null,
heading: "Formatted card",
body: '<p><strong>Safe</strong><script>bad()</script></p>',
percent: null,
backgroundColor: null,
highlights: [],
}],
}],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections[0].rows[0].body).toBe("<p><strong>Safe</strong></p>");
}
});
});
describe("Team Calc", () => {
+7 -2
View File
@@ -2,6 +2,7 @@ import * as z from "zod";
import { targetStatOptions } from "@/lib/guides/stats";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { normalizeExtraRichText } from "@/lib/rich-text";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
export type GuideSectionKind = (typeof guideSectionKinds)[number];
@@ -136,7 +137,7 @@ const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "กรุณาเลื
const extraRowSchema = z.strictObject({
mediaId: z.string().uuid().nullable(),
heading: z.string().trim().min(1).max(200),
body: z.string().trim().max(2_000),
body: z.string().trim().max(10_000),
percent: percent.nullable(),
backgroundColor: hexColor.nullable().default(null),
highlights: z.array(z.strictObject({
@@ -146,7 +147,11 @@ const extraRowSchema = z.strictObject({
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
).default([]),
});
}).transform((row) => ({
...row,
body: normalizeExtraRichText(row.body, row.highlights),
highlights: [],
}));
export const extraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
normalizeExtraRichText,
sanitizeRichTextHtml,
sanitizeRichTextUrl,
} from "@/lib/rich-text";
describe("rich text sanitization", () => {
it("keeps supported formatting, colors, and safe links", () => {
expect(sanitizeRichTextHtml(
'<p><strong>Good</strong> <span style="color: #ff3b30">news</span> <a href="https://example.com" onclick="bad()">link</a></p>',
)).toBe(
'<p><strong>Good</strong> <span style="color: #FF3B30">news</span> <a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a></p>',
);
});
it("removes executable markup, unsafe links, and unsupported styles", () => {
expect(sanitizeRichTextHtml(
'<script>alert(1)</script><p onclick="bad()">Safe <span style="background:red;color:red">text</span><a href="javascript:bad()">link</a></p>',
)).toBe("<p>Safe <span>text</span><a>link</a></p>");
expect(sanitizeRichTextUrl("data:text/html,bad")).toBe("");
});
});
describe("legacy Extra rich text conversion", () => {
it("escapes plain text, preserves newlines, and converts phrase colors", () => {
expect(normalizeExtraRichText("<unsafe>\nMASSIVE damage", [
{ text: "MASSIVE", color: "#ff0000" },
])).toBe(
'<p>&lt;unsafe&gt;<br><span style="color: #FF0000">MASSIVE</span> damage</p>',
);
});
it("leaves sanitized rich text in rich-text form", () => {
expect(normalizeExtraRichText('<p><em>Ready</em></p>', [
{ text: "Ready", color: "#ff0000" },
])).toBe("<p><em>Ready</em></p>");
});
});
+108
View File
@@ -0,0 +1,108 @@
import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights";
const ALLOWED_TAGS = new Set([
"a",
"blockquote",
"br",
"code",
"em",
"li",
"ol",
"p",
"pre",
"s",
"span",
"strong",
"u",
"ul",
]);
const BLOCKED_CONTENT = /<(script|style|iframe|object|embed|svg|math|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
const TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
export function sanitizeRichTextUrl(value: string): string {
const url = value.trim();
if (!url) return "";
try {
const parsed = new URL(url, "https://example.invalid");
return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? url : "";
} catch {
return "";
}
}
function escapeAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeHtml(value: string): string {
return escapeAttribute(value).replaceAll("'", "&#39;");
}
function attributeValue(rawAttributes: string, name: string): string {
for (const match of rawAttributes.matchAll(ATTRIBUTE)) {
if (match[1].toLowerCase() === name) {
return match[2].replace(/^['"]|['"]$/g, "");
}
}
return "";
}
export function sanitizeRichTextHtml(value: string): string {
return value
.replace(BLOCKED_CONTENT, "")
.replace(HTML_COMMENT, "")
.replace(TAG, (tag, rawName: string, rawAttributes: string) => {
const name = rawName.toLowerCase();
if (!ALLOWED_TAGS.has(name)) return "";
if (tag.startsWith("</")) return name === "br" ? "" : `</${name}>`;
if (name === "br") return "<br>";
if (name === "a") {
const href = sanitizeRichTextUrl(attributeValue(rawAttributes, "href"));
return href
? `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">`
: "<a>";
}
if (name === "span") {
const style = attributeValue(rawAttributes, "style");
const color = style.match(/(?:^|;)\s*color\s*:\s*(#[0-9a-fA-F]{6})\s*(?:;|$)/)?.[1];
return color && HEX_COLOR.test(color)
? `<span style="color: ${color.toUpperCase()}">`
: "<span>";
}
return `<${name}>`;
});
}
export function isRichTextHtml(value: string): boolean {
return /<(?:p|br|strong|em|u|s|a|span|ul|ol|li|blockquote|pre|code)\b/i.test(value);
}
export function normalizeExtraRichText(
body: string,
highlights: readonly TextHighlight[] = [],
): string {
if (isRichTextHtml(body)) return sanitizeRichTextHtml(body);
const content = highlightTextSegments(body, highlights)
.map((segment) => {
const text = escapeHtml(segment.text).replaceAll("\n", "<br>");
return segment.color && HEX_COLOR.test(segment.color)
? `<span style="color: ${segment.color.toUpperCase()}">${text}</span>`
: text;
})
.join("");
return content ? `<p>${content}</p>` : "";
}