fix(logs): rebuild legacy HTML safely
This commit is contained in:
@@ -1,39 +1,3 @@
|
||||
export const convertLog = (value: string, type = 1): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
let result = value;
|
||||
if (type > 0) {
|
||||
result = result.replaceAll('<1>', '<font size=1>');
|
||||
result = result.replaceAll('<Y1>', '<font size=1 color=yellow>');
|
||||
result = result.replaceAll('<R>', '<font color=red>');
|
||||
result = result.replaceAll('<B>', '<font color=blue>');
|
||||
result = result.replaceAll('<G>', '<font color=green>');
|
||||
result = result.replaceAll('<M>', '<font color=magenta>');
|
||||
result = result.replaceAll('<C>', '<font color=cyan>');
|
||||
result = result.replaceAll('<L>', '<font color=limegreen>');
|
||||
result = result.replaceAll('<S>', '<font color=skyblue>');
|
||||
result = result.replaceAll('<O>', '<font color=orangered>');
|
||||
result = result.replaceAll('<D>', '<font color=orangered>');
|
||||
result = result.replaceAll('<Y>', '<font color=yellow>');
|
||||
result = result.replaceAll('<W>', '<font color=white>');
|
||||
result = result.replaceAll('</>', '</font>');
|
||||
return result;
|
||||
}
|
||||
import { formatLegacyLogHtml } from '@sammo-ts/common';
|
||||
|
||||
result = result.replaceAll('<1>', '');
|
||||
result = result.replaceAll('<Y1>', '');
|
||||
result = result.replaceAll('<R>', '');
|
||||
result = result.replaceAll('<B>', '');
|
||||
result = result.replaceAll('<G>', '');
|
||||
result = result.replaceAll('<M>', '');
|
||||
result = result.replaceAll('<C>', '');
|
||||
result = result.replaceAll('<L>', '');
|
||||
result = result.replaceAll('<S>', '');
|
||||
result = result.replaceAll('<O>', '');
|
||||
result = result.replaceAll('<D>', '');
|
||||
result = result.replaceAll('<Y>', '');
|
||||
result = result.replaceAll('<W>', '');
|
||||
result = result.replaceAll('</>', '');
|
||||
return result;
|
||||
};
|
||||
export const convertLog = (value: string, type = 1): string => formatLegacyLogHtml(value, { colorize: type > 0 });
|
||||
|
||||
@@ -333,6 +333,21 @@ describe('battle sim processor', () => {
|
||||
expect(() => processBattleSimJob(payload)).toThrow('Unknown scenario effect: event_Missing');
|
||||
});
|
||||
|
||||
it('escapes executable markup from simulator display names while preserving legacy log structure', () => {
|
||||
const payload = buildPayload('battle');
|
||||
payload.attackerGeneral.name = '<img src=x onerror="globalThis.__battleLogXss=1">';
|
||||
payload.defenderGenerals[0]!.name = '<script>globalThis.__battleLogXss=2</script>';
|
||||
payload.attackerNation.name = '<svg onload="globalThis.__battleLogXss=3">국가</svg>';
|
||||
|
||||
const result = processBattleSimJob(payload);
|
||||
const html = JSON.stringify(result.lastWarLog);
|
||||
|
||||
expect(html).toContain('<img src=x onerror=');
|
||||
expect(html).toContain('<script>globalThis.__battleLogXss=2</script>');
|
||||
expect(html).toContain('<div class=\\"small_war_log\\">');
|
||||
expect(html).not.toMatch(/<img|<script|<svg/i);
|
||||
});
|
||||
|
||||
it('runs the advance trigger when a progressed attacker meets the next fresh defender', () => {
|
||||
const payload = buildPayload('battle');
|
||||
payload.scenarioEffect = 'event_StrongAttacker';
|
||||
@@ -346,7 +361,7 @@ describe('battle sim processor', () => {
|
||||
|
||||
const result = processBattleSimJob(payload);
|
||||
expect(result.lastWarLog?.generalBattleDetailLog).toContain(
|
||||
'적군의 전멸에 <font color=cyan>진격</font>이 이어집니다!'
|
||||
'적군의 전멸에 <span style="color: cyan;">진격</span>이 이어집니다!'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||
const artifactRoot = process.env.LEGACY_LOG_ARTIFACT_DIR ? resolve(process.env.LEGACY_LOG_ARTIFACT_DIR) : null;
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const history = [
|
||||
{
|
||||
id: 1,
|
||||
text: '<R><b>안전 강조</b></><img src=x onerror="globalThis.__legacyLogXss=1"><script>globalThis.__legacyLogXss=2</script>',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text:
|
||||
'<div class="small_war_log"><span class="me"><span class="name">정상 장수</span></span>' +
|
||||
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
||||
'<span class="name" onclick="globalThis.__legacyLogXss=3">오염 이름</span></div>',
|
||||
},
|
||||
];
|
||||
|
||||
const publicResponse = (operation: string): unknown => {
|
||||
if (operation === 'public.getMapLayout') return response({ mapName: 'che', cityList: [] });
|
||||
if (operation === 'public.getCachedMap') {
|
||||
return response({ year: 200, month: 1, cityList: [], nationList: [], history });
|
||||
}
|
||||
if (operation === 'public.getWorldTrend') return response({ year: 200, month: 1, turnTerm: 10 });
|
||||
if (operation === 'public.getNationList' || operation === 'public.getGeneralList') return response([]);
|
||||
throw new Error(`Unhandled legacy log fixture operation: ${operation}`);
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
delete (globalThis as Record<string, unknown>).__legacyLogXss;
|
||||
localStorage.removeItem('sammo-game-token');
|
||||
});
|
||||
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(operationNames(route).map(publicResponse)),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
] as const) {
|
||||
test(`renders only rebuilt legacy log markup on ${viewport.name}`, async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto('public');
|
||||
|
||||
const lines = page.locator('.recent-log-line');
|
||||
await expect(lines).toHaveCount(2);
|
||||
await expect(lines.nth(0).locator('b')).toHaveText('안전 강조');
|
||||
await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).toHaveText('→');
|
||||
await expect(lines.nth(1).locator('.ev_highlight')).toHaveText('강조');
|
||||
await expect(lines.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0);
|
||||
await expect(lines.nth(0)).toContainText('<img src=x onerror=');
|
||||
await expect(lines.nth(1)).toContainText('<span class="name" onclick=');
|
||||
expect(await page.evaluate(() => (globalThis as Record<string, unknown>).__legacyLogXss)).toBeUndefined();
|
||||
|
||||
const geometry = await lines.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const colorSpan = element.querySelector<HTMLElement>('span[style]');
|
||||
const bold = element.querySelector<HTMLElement>('b');
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: colorSpan ? getComputedStyle(colorSpan).color : null,
|
||||
fontWeight: bold ? getComputedStyle(bold).fontWeight : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
expect(geometry[0]?.width).toBeGreaterThan(0);
|
||||
expect(geometry[0]?.color).toBe('rgb(255, 0, 0)');
|
||||
expect(Number(geometry[0]?.fontWeight)).toBeGreaterThanOrEqual(600);
|
||||
|
||||
const refresh = page.getByRole('button', { name: '새로고침' });
|
||||
await refresh.focus();
|
||||
await expect(refresh).toBeFocused();
|
||||
await refresh.hover();
|
||||
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const name = `legacy-log-${basePath.slice(1)}-${viewport.name}`;
|
||||
await writeFile(
|
||||
resolve(artifactRoot, `${name}.json`),
|
||||
`${JSON.stringify({ basePath, viewport, geometry }, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
'inGameMenus.spec.ts',
|
||||
'nationOffices.spec.ts',
|
||||
'diplomacy.spec.ts',
|
||||
'legacyLogHtml.spec.ts',
|
||||
'directoryLists.spec.ts',
|
||||
'pastPlays.spec.ts',
|
||||
'nationGeneralSecret.spec.ts',
|
||||
|
||||
@@ -1,55 +1,3 @@
|
||||
const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g;
|
||||
import { formatLegacyLogHtml } from '@sammo-ts/common';
|
||||
|
||||
const convertMap: Record<string, string> = {
|
||||
R: 'color: red;',
|
||||
B: 'color: blue;',
|
||||
G: 'color: green;',
|
||||
M: 'color: magenta;',
|
||||
C: 'color: cyan;',
|
||||
L: 'color: limegreen;',
|
||||
S: 'color: skyblue;',
|
||||
O: 'color: orangered;',
|
||||
D: 'color: orangered;',
|
||||
Y: 'color: yellow;',
|
||||
W: 'color: white;',
|
||||
1: 'font-size: 0.9em;',
|
||||
};
|
||||
|
||||
const convertMap2: Record<string, string> = {
|
||||
1: 'font-size: 0.9em;',
|
||||
};
|
||||
|
||||
export const formatLog = (text?: string): string => {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let lastIndex = 0;
|
||||
const result: string[] = [];
|
||||
|
||||
for (let match = logRegex.exec(text); match !== null; match = logRegex.exec(text)) {
|
||||
const partAll = match[0];
|
||||
const subPart = match[1];
|
||||
const index = match.index;
|
||||
|
||||
if (lastIndex !== index) {
|
||||
result.push(text.slice(lastIndex, index));
|
||||
}
|
||||
|
||||
if (subPart === '/') {
|
||||
result.push('</span>');
|
||||
} else if (subPart.length === 2) {
|
||||
result.push(`<span style="${convertMap[subPart[0]] ?? ''}${convertMap2[subPart[1]] ?? ''}">`);
|
||||
} else {
|
||||
result.push(`<span style="${convertMap[subPart] ?? ''}">`);
|
||||
}
|
||||
|
||||
lastIndex = index + partAll.length;
|
||||
}
|
||||
|
||||
if (lastIndex !== text.length) {
|
||||
result.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
};
|
||||
export const formatLog = (text?: string): string => formatLegacyLogHtml(text);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const artifactRoot = process.env.LEGACY_LOG_ARTIFACT_DIR ? resolve(process.env.LEGACY_LOG_ARTIFACT_DIR) : null;
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route): string[] => {
|
||||
const url = new URL(route.request().url());
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const history = [
|
||||
{
|
||||
id: 1,
|
||||
text: '<R><b>안전 강조</b></><img src=x onerror="globalThis.__legacyLogXss=1"><script>globalThis.__legacyLogXss=2</script>',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text:
|
||||
'<div class="small_war_log"><span class="me"><span class="name">정상 장수</span></span>' +
|
||||
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
||||
'<span class="name" onclick="globalThis.__legacyLogXss=3">오염 이름</span></div>',
|
||||
},
|
||||
];
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
delete (globalThis as Record<string, unknown>).__legacyLogXss;
|
||||
localStorage.removeItem('sammo-session-token');
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'me') return response(null);
|
||||
if (operation === 'lobby.profiles') {
|
||||
return response([
|
||||
{
|
||||
profileName: 'che:2',
|
||||
profile: 'che',
|
||||
scenario: '2',
|
||||
status: 'RUNNING',
|
||||
apiPort: 15003,
|
||||
korName: '체',
|
||||
color: '#ffffff',
|
||||
runtime: {
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: true,
|
||||
battleSimRunning: true,
|
||||
tournamentRunning: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
throw new Error(`Unhandled gateway legacy log fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
await page.route('**/che/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'lobby.info') {
|
||||
return response({
|
||||
year: 200,
|
||||
month: 1,
|
||||
userCnt: 1,
|
||||
maxUserCnt: 100,
|
||||
npcCnt: 0,
|
||||
nationCnt: 1,
|
||||
turnTerm: 10,
|
||||
fictionMode: '가상',
|
||||
starttime: '2026-07-31T00:00:00.000Z',
|
||||
opentime: '2026-07-31T00:00:00.000Z',
|
||||
turntime: '2026-07-31T00:10:00.000Z',
|
||||
otherTextInfo: '',
|
||||
isUnited: false,
|
||||
selectionPoolEnabled: false,
|
||||
npcPossessionEnabled: false,
|
||||
myGeneral: null,
|
||||
});
|
||||
}
|
||||
if (operation === 'public.getMapLayout') return response({ mapName: 'che', cityList: [] });
|
||||
if (operation === 'public.getCachedMap') {
|
||||
return response({ year: 200, month: 1, cityList: [], nationList: [], history });
|
||||
}
|
||||
throw new Error(`Unhandled game legacy log fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
for (const viewport of [
|
||||
{ name: 'desktop', width: 1200, height: 900 },
|
||||
{ name: 'mobile', width: 500, height: 900 },
|
||||
] as const) {
|
||||
test(`renders only rebuilt public history markup on ${viewport.name}`, async ({ page }) => {
|
||||
await installFixture(page);
|
||||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||
await page.goto('/gateway/');
|
||||
|
||||
const lines = page.locator('.status-history > div');
|
||||
await expect(lines).toHaveCount(2);
|
||||
await expect(lines.nth(0).locator('b')).toHaveText('안전 강조');
|
||||
await expect(lines.nth(1).locator('.small_war_log .war_type_attack')).toHaveText('→');
|
||||
await expect(lines.nth(1).locator('.ev_highlight')).toHaveText('강조');
|
||||
await expect(lines.locator('script, img, svg, a, [onerror], [onclick], [style*="url"]')).toHaveCount(0);
|
||||
await expect(lines.nth(0)).toContainText('<img src=x onerror=');
|
||||
await expect(lines.nth(1)).toContainText('<span class="name" onclick=');
|
||||
expect(await page.evaluate(() => (globalThis as Record<string, unknown>).__legacyLogXss)).toBeUndefined();
|
||||
|
||||
const geometry = await lines.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const colorSpan = element.querySelector<HTMLElement>('span[style]');
|
||||
const bold = element.querySelector<HTMLElement>('b');
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
color: colorSpan ? getComputedStyle(colorSpan).color : null,
|
||||
fontWeight: bold ? getComputedStyle(bold).fontWeight : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
expect(geometry[0]?.width).toBeGreaterThan(0);
|
||||
expect(geometry[0]?.color).toBe('rgb(255, 0, 0)');
|
||||
expect(Number(geometry[0]?.fontWeight)).toBeGreaterThanOrEqual(600);
|
||||
|
||||
const refresh = page.getByRole('button', { name: '현황 새로고침' });
|
||||
await refresh.focus();
|
||||
await expect(refresh).toBeFocused();
|
||||
await refresh.hover();
|
||||
|
||||
if (artifactRoot) {
|
||||
await mkdir(artifactRoot, { recursive: true });
|
||||
const name = `legacy-log-gateway-${viewport.name}`;
|
||||
await writeFile(
|
||||
resolve(artifactRoot, `${name}.json`),
|
||||
`${JSON.stringify({ viewport, geometry }, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await page.screenshot({ path: resolve(artifactRoot, `${name}.png`), fullPage: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
'lobby-game-auth.spec.ts',
|
||||
'logout.spec.ts',
|
||||
'account-icon-sync.spec.ts',
|
||||
'legacy-log-html.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -1,26 +1,3 @@
|
||||
const logRegex = /<([RBGMCLSODYW]1?|1|\/)>/g;
|
||||
import { formatLegacyLogHtml } from '@sammo-ts/common';
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
R: 'red',
|
||||
B: 'blue',
|
||||
G: 'green',
|
||||
M: 'magenta',
|
||||
C: 'cyan',
|
||||
L: 'limegreen',
|
||||
S: 'skyblue',
|
||||
O: 'orangered',
|
||||
D: 'orangered',
|
||||
Y: 'yellow',
|
||||
W: 'white',
|
||||
};
|
||||
|
||||
export const formatLog = (text: string): string =>
|
||||
text.replace(logRegex, (_all, tag: string) => {
|
||||
if (tag === '/') {
|
||||
return '</span>';
|
||||
}
|
||||
const color = colorMap[tag[0] ?? ''];
|
||||
const small = tag.includes('1');
|
||||
const styles = [color ? `color: ${color}` : '', small ? 'font-size: 0.9em' : ''].filter(Boolean).join('; ');
|
||||
return `<span style="${styles}">`;
|
||||
});
|
||||
export const formatLog = (text: string): string => formatLegacyLogHtml(text);
|
||||
|
||||
@@ -181,7 +181,7 @@ const handlePasswordReset = async (): Promise<void> => {
|
||||
<li>{{ info.turnTerm }}분 턴 서버</li>
|
||||
</ul>
|
||||
<div v-if="mapData?.history?.length" class="status-history">
|
||||
<!-- 레거시 색상 tag만 formatLog가 span으로 변환한다. -->
|
||||
<!-- 레거시 로그의 허용된 색상·강조·전투 구조만 안전한 HTML로 재구성한다. -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-for="entry in mapData.history" :key="entry.id" v-html="formatLog(entry.text)" />
|
||||
</div>
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from './realtime/types.js';
|
||||
export * from './ranking/types.js';
|
||||
export * from './ranking/legacyColor.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
export * from './logging/formatLegacyLogHtml.js';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const legacyTagPattern = /^<([RBGMCLSODYW]1?|1|\/)>$/;
|
||||
|
||||
const legacyStyleMap: Record<string, string> = {
|
||||
R: 'color: red;',
|
||||
B: 'color: blue;',
|
||||
G: 'color: green;',
|
||||
M: 'color: magenta;',
|
||||
C: 'color: cyan;',
|
||||
L: 'color: limegreen;',
|
||||
S: 'color: skyblue;',
|
||||
O: 'color: orangered;',
|
||||
D: 'color: orangered;',
|
||||
Y: 'color: yellow;',
|
||||
W: 'color: white;',
|
||||
1: 'font-size: 0.9em;',
|
||||
};
|
||||
|
||||
const safeSpanClasses = new Set([
|
||||
'ev_highlight',
|
||||
'ev_failed',
|
||||
'ev_notice',
|
||||
'me',
|
||||
'you',
|
||||
'name_plate',
|
||||
'crew_type',
|
||||
'name_plate_cover',
|
||||
'crew_plate',
|
||||
'remain_crew',
|
||||
'killed_plate',
|
||||
'killed_crew',
|
||||
'name',
|
||||
'war_type',
|
||||
'war_type_attack',
|
||||
'war_type_defense',
|
||||
'war_type_siege',
|
||||
]);
|
||||
|
||||
const escapeText = (value: string): string =>
|
||||
value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
|
||||
const normalizeStructuralTag = (tag: string): string | null => {
|
||||
if (/^<b\s*>$/i.test(tag)) return '<b>';
|
||||
if (/^<\/b\s*>$/i.test(tag)) return '</b>';
|
||||
if (/^<br\s*\/?\s*>$/i.test(tag)) return '<br>';
|
||||
if (/^<\/span\s*>$/i.test(tag)) return '</span>';
|
||||
if (/^<\/div\s*>$/i.test(tag)) return '</div>';
|
||||
|
||||
const colorSpan = tag.match(/^<span\s+style\s*=\s*(['"])\s*color\s*:\s*(#[0-9a-f]{6})\s*;?\s*\1\s*>$/i);
|
||||
if (colorSpan) return `<span style="color: ${colorSpan[2]};">`;
|
||||
|
||||
const open = tag.match(/^<(span|div)\s+class\s*=\s*(['"])([^'"]+)\2\s*>$/i);
|
||||
if (!open) return null;
|
||||
|
||||
const element = open[1]!.toLowerCase();
|
||||
const classes = open[3]!.trim().split(/\s+/u);
|
||||
if (element === 'div') {
|
||||
return classes.length === 1 && classes[0] === 'small_war_log' ? '<div class="small_war_log">' : null;
|
||||
}
|
||||
if (classes.length === 0 || classes.some((className) => !safeSpanClasses.has(className))) {
|
||||
return null;
|
||||
}
|
||||
return `<span class="${classes.join(' ')}">`;
|
||||
};
|
||||
|
||||
export type FormatLegacyLogHtmlOptions = {
|
||||
colorize?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Ref's custom color markers while rebuilding only the small set of
|
||||
* HTML structures emitted by legacy log writers. Everything else is text.
|
||||
*/
|
||||
export const formatLegacyLogHtml = (value?: string | null, options: FormatLegacyLogHtmlOptions = {}): string => {
|
||||
if (!value) return '';
|
||||
|
||||
const colorize = options.colorize ?? true;
|
||||
return value
|
||||
.split(/(<[^>]*>)/gu)
|
||||
.map((part) => {
|
||||
if (!part.startsWith('<') || !part.endsWith('>')) {
|
||||
return escapeText(part);
|
||||
}
|
||||
|
||||
const legacy = part.match(legacyTagPattern)?.[1];
|
||||
if (legacy) {
|
||||
if (!colorize) return '';
|
||||
if (legacy === '/') return '</span>';
|
||||
const colorCode = legacy === '1' ? null : legacy[0]!;
|
||||
const small = legacy === '1' || legacy.endsWith('1');
|
||||
return `<span style="${colorCode ? (legacyStyleMap[colorCode] ?? '') : ''}${small ? legacyStyleMap['1'] : ''}">`;
|
||||
}
|
||||
|
||||
return normalizeStructuralTag(part) ?? escapeText(part);
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatLegacyLogHtml } from '../src/logging/formatLegacyLogHtml.js';
|
||||
|
||||
describe('formatLegacyLogHtml', () => {
|
||||
it('converts legacy colors and preserves intentional emphasis and line breaks', () => {
|
||||
expect(formatLegacyLogHtml('<R><b>위험</b></><br><Y1>작게</><1>작게만</>')).toBe(
|
||||
'<span style="color: red;"><b>위험</b></span><br><span style="color: yellow;font-size: 0.9em;">작게</span><span style="font-size: 0.9em;">작게만</span>'
|
||||
);
|
||||
expect(formatLegacyLogHtml('<R>색상</>', { colorize: false })).toBe('색상');
|
||||
});
|
||||
|
||||
it('escapes executable and unknown markup instead of passing it to v-html', () => {
|
||||
const dirty = [
|
||||
'<script>globalThis.__logXss=1</script>',
|
||||
'<img src=x onerror="globalThis.__logXss=2">',
|
||||
'<svg><a href="javascript:alert(1)">SVG</a></svg>',
|
||||
'<span class="name" onclick="alert(2)">이름</span>',
|
||||
'<a href="javascript:alert(3)">링크</a>',
|
||||
].join('');
|
||||
const clean = formatLegacyLogHtml(dirty);
|
||||
|
||||
expect(clean).toContain('<script>globalThis.__logXss=1</script>');
|
||||
expect(clean).toContain('<img src=x onerror="globalThis.__logXss=2">');
|
||||
expect(clean).toContain('<span class="name" onclick="alert(2)">이름</span>');
|
||||
expect(clean).not.toMatch(/<script|<img|<svg|<a /i);
|
||||
});
|
||||
|
||||
it('rebuilds only the classed markup emitted by battle and tournament logs', () => {
|
||||
const source =
|
||||
'<div class="small_war_log"><span class="me"><span class="name">장수</span></span>' +
|
||||
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
||||
"<span class='ev_failed'>실패</span><span class='ev_notice'>주의</span></div>";
|
||||
expect(formatLegacyLogHtml(source)).toBe(
|
||||
'<div class="small_war_log"><span class="me"><span class="name">장수</span></span>' +
|
||||
'<span class="war_type war_type_attack">→</span><span class="ev_highlight">강조</span>' +
|
||||
'<span class="ev_failed">실패</span><span class="ev_notice">주의</span></div>'
|
||||
);
|
||||
expect(formatLegacyLogHtml('<span class="unknown">미허용</span>')).toBe(
|
||||
'<span class="unknown">미허용</span>'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fixed hex color form emitted by flag-change logs but rejects other inline CSS', () => {
|
||||
expect(formatLegacyLogHtml("<span style='color:#FF6347;'><b>국기</b></span>")).toBe(
|
||||
'<span style="color: #FF6347;"><b>국기</b></span>'
|
||||
);
|
||||
expect(formatLegacyLogHtml("<span style='color:red;background:url(javascript:x)'>오염</span>")).toBe(
|
||||
"<span style='color:red;background:url(javascript:x)'>오염</span>"
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes dynamic text and incomplete tags', () => {
|
||||
expect(formatLegacyLogHtml('A&B <img src=x')).toBe('A&B <img src=x');
|
||||
expect(formatLegacyLogHtml('<script>')).toBe('&#60;script&#62;');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user