fix(diplomacy): purify stored letter HTML

This commit is contained in:
2026-07-31 15:52:43 +00:00
parent aaab3f1a91
commit eb9c1fe1e7
7 changed files with 679 additions and 4 deletions
+6 -4
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import type { GamePrisma } from '@sammo-ts/infra';
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
import { getMyGeneral } from '../shared/general.js';
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
@@ -56,7 +57,8 @@ export const diplomacyRouter = router({
const src = asRecord(aux.src);
const dest = asRecord(aux.dest);
const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null;
const detail = permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : letter.textDetail;
const detail =
permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : purifyDiplomacyHtml(letter.textDetail);
const reason = asRecord(aux.reason);
return {
@@ -80,7 +82,7 @@ export const diplomacyRouter = router({
prevId: letter.prevId,
state: mapLetterState(letter.state),
stateOpt,
brief: letter.textBrief,
brief: purifyDiplomacyHtml(letter.textBrief),
detail,
date: letter.date.toISOString(),
reason: {
@@ -207,8 +209,8 @@ export const diplomacyRouter = router({
destNationId: destNation.id,
prevId,
state: 'PROPOSED',
textBrief: input.brief,
textDetail: input.detail,
textBrief: purifyDiplomacyHtml(input.brief),
textDetail: purifyDiplomacyHtml(input.detail),
srcSignerId: me.id,
aux: aux as GamePrisma.InputJsonValue,
},
@@ -0,0 +1,68 @@
import sanitizeHtml from 'sanitize-html';
const options: sanitizeHtml.IOptions = {
allowedTags: [
'p',
'br',
'strong',
'b',
'em',
'i',
'u',
's',
'strike',
'blockquote',
'ul',
'ol',
'li',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'code',
'pre',
'hr',
'a',
'img',
],
allowedAttributes: {
a: ['href', 'target', 'rel', 'title'],
img: ['src', 'alt', 'title', 'width', 'height'],
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowedSchemesByTag: {
a: ['http', 'https', 'mailto', 'tel'],
img: ['http', 'https'],
},
allowProtocolRelative: false,
transformTags: {
a: (tagName, attribs) => {
const target = attribs.target === '_blank' || attribs.target === '_self' ? attribs.target : undefined;
const { target: _target, rel: _rel, ...rest } = attribs;
return {
tagName,
attribs: target
? {
...rest,
target,
...(target === '_blank' ? { rel: 'noopener noreferrer nofollow' } : {}),
}
: rest,
};
},
},
exclusiveFilter: (frame) => frame.tag === 'img' && !frame.attribs.src,
};
/**
* Canonicalizes the HTML emitted by the diplomacy Tiptap editors. Writes are
* purified before persistence and reads are purified again for legacy rows.
*/
export const purifyDiplomacyHtml = (value: string | null | undefined): string => {
if (!value) {
return '';
}
return sanitizeHtml(value, options);
};