fix(gateway): purify global notice HTML
This commit is contained in:
@@ -24,6 +24,7 @@
|
|||||||
"typecheck": "tsc -b"
|
"typecheck": "tsc -b"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/sanitize-html": "2.16.1",
|
||||||
"tsdown": "^0.18.4",
|
"tsdown": "^0.18.4",
|
||||||
"vite-tsconfig-paths": "^6.0.3",
|
"vite-tsconfig-paths": "^6.0.3",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
"pm2": "^5.4.3",
|
"pm2": "^5.4.3",
|
||||||
"redis": "^5.10.0",
|
"redis": "^5.10.0",
|
||||||
|
"sanitize-html": "2.17.6",
|
||||||
"sharp": "^0.34.4",
|
"sharp": "^0.34.4",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { toPublicUser } from './auth/userRepository.js';
|
|||||||
import type { AdminAuthContext } from './adminAuth.js';
|
import type { AdminAuthContext } from './adminAuth.js';
|
||||||
import type { GatewayApiContext } from './context.js';
|
import type { GatewayApiContext } from './context.js';
|
||||||
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
|
||||||
|
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||||
|
|
||||||
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
|
||||||
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
|
||||||
@@ -399,7 +400,7 @@ export const adminRouter = router({
|
|||||||
const setting = await ctx.prisma.systemSetting.findUnique({
|
const setting = await ctx.prisma.systemSetting.findUnique({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
});
|
});
|
||||||
return { notice: setting?.notice ?? '' };
|
return { notice: purifyGatewayNoticeHtml(setting?.notice) };
|
||||||
}),
|
}),
|
||||||
setNotice: noticeAdminProcedure
|
setNotice: noticeAdminProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -408,17 +409,18 @@ export const adminRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const notice = purifyGatewayNoticeHtml(input.notice);
|
||||||
const setting = await ctx.prisma.systemSetting.upsert({
|
const setting = await ctx.prisma.systemSetting.upsert({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
create: {
|
create: {
|
||||||
id: 1,
|
id: 1,
|
||||||
notice: input.notice,
|
notice,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
notice: input.notice,
|
notice,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { notice: setting.notice };
|
return { notice: purifyGatewayNoticeHtml(setting.notice) };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
users: router({
|
users: router({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { accountRouter } from './account/router.js';
|
|||||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||||
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
||||||
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
|
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
|
||||||
|
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
|
||||||
|
|
||||||
const zUsername = z
|
const zUsername = z
|
||||||
.string()
|
.string()
|
||||||
@@ -51,7 +52,7 @@ export const appRouter = router({
|
|||||||
const setting = await ctx.prisma.systemSetting.findUnique({
|
const setting = await ctx.prisma.systemSetting.findUnique({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
});
|
});
|
||||||
return setting?.notice ?? '';
|
return purifyGatewayNoticeHtml(setting?.notice);
|
||||||
}),
|
}),
|
||||||
profiles: procedure
|
profiles: procedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
|
const safeNamedColor =
|
||||||
|
/^(?:black|silver|gray|white|maroon|red|purple|fuchsia|green|lime|olive|yellow|navy|blue|teal|aqua|orange|cyan|magenta|skyblue|orangered|limegreen|darkorange)$/i;
|
||||||
|
const safeFontColor = (value: string): boolean => /^#[0-9a-f]{3,8}$/i.test(value) || safeNamedColor.test(value);
|
||||||
|
const safeFontFace = /^[\p{L}\p{N}\s,'".-]+$/u;
|
||||||
|
|
||||||
|
const options: sanitizeHtml.IOptions = {
|
||||||
|
allowedTags: ['br', 'b', 'strong', 'em', 'i', 'u', 's', 'strike', 'small', 'sup', 'sub', 'span', 'a', 'font'],
|
||||||
|
allowedAttributes: {
|
||||||
|
'*': ['style', 'title'],
|
||||||
|
a: ['href', 'target', 'rel', 'title', 'style'],
|
||||||
|
font: ['color', 'size', 'face', 'style', 'title'],
|
||||||
|
},
|
||||||
|
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||||
|
allowProtocolRelative: false,
|
||||||
|
allowedStyles: {
|
||||||
|
'*': {
|
||||||
|
color: [/^#[0-9a-f]{3,8}$/i, /^rgba?\([\d\s.,%]+\)$/i, safeNamedColor],
|
||||||
|
'font-size': [
|
||||||
|
/^\d+(?:\.\d+)?(?:px|pt|em|rem|%)$/i,
|
||||||
|
/^(?:xx-small|x-small|small|medium|large|x-large|xx-large)$/i,
|
||||||
|
],
|
||||||
|
'font-style': [/^(?:normal|italic|oblique)$/i],
|
||||||
|
'font-weight': [/^(?:normal|bold|bolder|lighter|[1-9]00)$/i],
|
||||||
|
'text-decoration': [
|
||||||
|
/^(?:none|underline|line-through|overline)(?:\s+(?:underline|line-through|overline))*$/i,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
font: (tagName, attribs) => ({
|
||||||
|
tagName,
|
||||||
|
attribs: {
|
||||||
|
...(attribs.color && safeFontColor(attribs.color) ? { color: attribs.color } : {}),
|
||||||
|
...(attribs.size && /^[1-7]$/.test(attribs.size) ? { size: attribs.size } : {}),
|
||||||
|
...(attribs.face && safeFontFace.test(attribs.face) ? { face: attribs.face } : {}),
|
||||||
|
...(attribs.style ? { style: attribs.style } : {}),
|
||||||
|
...(attribs.title ? { title: attribs.title } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalizes Ref-compatible gateway notice markup on both writes and reads.
|
||||||
|
* Reads are purified again so pre-existing rows cannot execute active content.
|
||||||
|
*/
|
||||||
|
export const purifyGatewayNoticeHtml = (value: string | null | undefined): string => {
|
||||||
|
if (!value) return '';
|
||||||
|
return sanitizeHtml(value, options);
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ const buildCaller = async (
|
|||||||
adminRoles?: string[];
|
adminRoles?: string[];
|
||||||
firstUserIsAdmin?: boolean;
|
firstUserIsAdmin?: boolean;
|
||||||
runtimeActionCreateError?: unknown;
|
runtimeActionCreateError?: unknown;
|
||||||
|
initialNotice?: string;
|
||||||
} = {}
|
} = {}
|
||||||
) => {
|
) => {
|
||||||
const users = createInMemoryUserRepository();
|
const users = createInMemoryUserRepository();
|
||||||
@@ -35,6 +36,7 @@ const buildCaller = async (
|
|||||||
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
|
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
|
||||||
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
const createdRuntimeActions: Array<Record<string, unknown>> = [];
|
||||||
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
|
||||||
|
let storedNotice = options.initialNotice ?? '';
|
||||||
const profile = {
|
const profile = {
|
||||||
profileName: 'che:2',
|
profileName: 'che:2',
|
||||||
profile: 'che',
|
profile: 'che',
|
||||||
@@ -139,12 +141,76 @@ const buildCaller = async (
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
systemSetting: {
|
||||||
|
findUnique: async () => ({ id: 1, notice: storedNotice }),
|
||||||
|
upsert: async ({ create, update }: { create: { notice: string }; update: { notice: string } }) => {
|
||||||
|
storedNotice = update.notice ?? create.notice;
|
||||||
|
return { id: 1, notice: storedNotice };
|
||||||
|
},
|
||||||
|
},
|
||||||
} as unknown as GatewayPrismaClient,
|
} as unknown as GatewayPrismaClient,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return { caller, createdInputs, createdRuntimeActions, users, admin, flushes };
|
return {
|
||||||
|
caller,
|
||||||
|
createdInputs,
|
||||||
|
createdRuntimeActions,
|
||||||
|
users,
|
||||||
|
admin,
|
||||||
|
flushes,
|
||||||
|
getStoredNotice: () => storedNotice,
|
||||||
|
setStoredNotice: (notice: string) => {
|
||||||
|
storedNotice = notice;
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe('gateway notice API', () => {
|
||||||
|
const dirtyNotice =
|
||||||
|
'<b>점검</b><br><script>globalThis.__noticeXss=1</script>' +
|
||||||
|
'<img src=x onerror="globalThis.__noticeXss=2">' +
|
||||||
|
'<a href="javascript:globalThis.__noticeXss=3">링크</a>';
|
||||||
|
|
||||||
|
it('purifies notices before persistence and returns the canonical value', async () => {
|
||||||
|
const harness = await buildCaller(async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.caller.admin.system.setNotice({ notice: dirtyNotice });
|
||||||
|
|
||||||
|
expect(result.notice).toBe('<b>점검</b><br /><a>링크</a>');
|
||||||
|
expect(harness.getStoredNotice()).toBe(result.notice);
|
||||||
|
await expect(harness.caller.admin.system.getNotice()).resolves.toEqual(result);
|
||||||
|
await expect(harness.caller.lobby.notice()).resolves.toBe(result.notice);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purifies pre-existing rows again on public and admin reads', async () => {
|
||||||
|
const harness = await buildCaller(async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
});
|
||||||
|
harness.setStoredNotice(dirtyNotice);
|
||||||
|
|
||||||
|
await expect(harness.caller.lobby.notice()).resolves.toBe('<b>점검</b><br /><a>링크</a>');
|
||||||
|
await expect(harness.caller.admin.system.getNotice()).resolves.toEqual({
|
||||||
|
notice: '<b>점검</b><br /><a>링크</a>',
|
||||||
|
});
|
||||||
|
expect(harness.getStoredNotice()).toBe(dirtyNotice);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps notice writes behind the dedicated admin role', async () => {
|
||||||
|
const harness = await buildCaller(
|
||||||
|
async () => {
|
||||||
|
throw new Error('not used');
|
||||||
|
},
|
||||||
|
{ adminRoles: [], firstUserIsAdmin: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(harness.caller.admin.system.setNotice({ notice: '<b>공지</b>' })).rejects.toMatchObject({
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('admin operation API', () => {
|
describe('admin operation API', () => {
|
||||||
it('queues a start operation with the authenticated requester', async () => {
|
it('queues a start operation with the authenticated requester', async () => {
|
||||||
const operation = {
|
const operation = {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { purifyGatewayNoticeHtml } from '../src/security/gatewayNoticeHtml.js';
|
||||||
|
|
||||||
|
describe('purifyGatewayNoticeHtml', () => {
|
||||||
|
it('keeps the small inline formatting set used by Ref gateway notices', () => {
|
||||||
|
const source =
|
||||||
|
'<b>점검</b><br><span style="color:#ff6600;font-size:1.2em;font-weight:bold">20시</span> ' +
|
||||||
|
'<font color="yellow" size="2" face="sans-serif">안내</font> ' +
|
||||||
|
'<a href="https://example.test/notice" target="_blank">상세</a>';
|
||||||
|
const clean = purifyGatewayNoticeHtml(source);
|
||||||
|
|
||||||
|
expect(clean).toContain('<b>점검</b><br />');
|
||||||
|
expect(clean).toContain('style="color:#ff6600;font-size:1.2em;font-weight:bold"');
|
||||||
|
expect(clean).toContain('<font color="yellow" size="2" face="sans-serif">안내</font>');
|
||||||
|
expect(clean).toContain('rel="noopener noreferrer nofollow"');
|
||||||
|
expect(purifyGatewayNoticeHtml(clean)).toBe(clean);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes executable tags, handlers, unsafe URLs and CSS while keeping their plain text', () => {
|
||||||
|
const source =
|
||||||
|
'<script>globalThis.__noticeXss=1</script>' +
|
||||||
|
'<img src=x onerror="globalThis.__noticeXss=2">' +
|
||||||
|
'<svg onload="globalThis.__noticeXss=3"><text>SVG</text></svg>' +
|
||||||
|
'<span onclick="globalThis.__noticeXss=4" style="color:red;background:url(javascript:x)">문구</span>' +
|
||||||
|
'<font color="expression" style="color:blue;background:url(javascript:y)">폰트</font>' +
|
||||||
|
'<a href="javascript:globalThis.__noticeXss=5">링크</a>';
|
||||||
|
const clean = purifyGatewayNoticeHtml(source);
|
||||||
|
|
||||||
|
expect(clean).not.toMatch(/script|<img|<svg|onerror|onload|onclick|javascript:|background/i);
|
||||||
|
expect(clean).toContain('<span style="color:red">문구</span>');
|
||||||
|
expect(clean).toContain('<font style="color:blue">폰트</font>');
|
||||||
|
expect(clean).toContain('<a>링크</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes empty values', () => {
|
||||||
|
expect(purifyGatewayNoticeHtml(undefined)).toBe('');
|
||||||
|
expect(purifyGatewayNoticeHtml(null)).toBe('');
|
||||||
|
expect(purifyGatewayNoticeHtml('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 artifactRoot = process.env.GATEWAY_NOTICE_ARTIFACT_DIR ? resolve(process.env.GATEWAY_NOTICE_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 safeNotice =
|
||||||
|
'<b>서버 점검</b><br /><span style="color:#00ff00;font-size:1.2em">20시 재개</span> ' +
|
||||||
|
'<a href="https://example.test/notice" target="_blank" rel="noopener noreferrer nofollow">상세</a>';
|
||||||
|
|
||||||
|
const installFixture = async (page: Page) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('sammo-session-token', 'playwright-notice-session');
|
||||||
|
delete (globalThis as Record<string, unknown>).__noticeXss;
|
||||||
|
});
|
||||||
|
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||||
|
const results = operationNames(route).map((operation) => {
|
||||||
|
if (operation === 'me') {
|
||||||
|
return response({
|
||||||
|
id: 'notice-user',
|
||||||
|
username: 'notice-user',
|
||||||
|
displayName: '공지 확인 사용자',
|
||||||
|
roles: [],
|
||||||
|
kakaoVerified: true,
|
||||||
|
createdAt: '2026-07-31T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (operation === 'lobby.notice') return response(safeNotice);
|
||||||
|
if (operation === 'lobby.profiles') return response([]);
|
||||||
|
throw new Error(`Unhandled gateway notice 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 the purified gateway notice on ${viewport.name}`, async ({ page }) => {
|
||||||
|
await installFixture(page);
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
await page.goto('/gateway/lobby');
|
||||||
|
|
||||||
|
const notice = page.getByTestId('gateway-notice');
|
||||||
|
await expect(notice).toBeVisible();
|
||||||
|
await expect(notice.locator('b')).toHaveText('서버 점검');
|
||||||
|
await expect(notice.locator('br')).toHaveCount(1);
|
||||||
|
await expect(notice.locator('span')).toHaveText('20시 재개');
|
||||||
|
await expect(notice.locator('script, img, svg, iframe, [onerror], [onload], [onclick]')).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => (globalThis as Record<string, unknown>).__noticeXss)).toBeUndefined();
|
||||||
|
|
||||||
|
const link = notice.getByRole('link', { name: '상세' });
|
||||||
|
await expect(link).toHaveAttribute('href', 'https://example.test/notice');
|
||||||
|
await expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||||
|
await link.focus();
|
||||||
|
await expect(link).toBeFocused();
|
||||||
|
await link.hover();
|
||||||
|
|
||||||
|
const geometry = await notice.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
const emphasis = element.querySelector<HTMLElement>('span');
|
||||||
|
const emphasisStyle = emphasis ? getComputedStyle(emphasis) : null;
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
color: style.color,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
fontWeight: style.fontWeight,
|
||||||
|
emphasisColor: emphasisStyle?.color ?? null,
|
||||||
|
emphasisFontSize: emphasisStyle?.fontSize ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBeGreaterThan(0);
|
||||||
|
expect(geometry.y).toBe(96);
|
||||||
|
expect(geometry.color).toBe('oklch(0.705 0.213 47.604)');
|
||||||
|
expect(geometry.fontSize).toBe('30px');
|
||||||
|
expect(Number(geometry.fontWeight)).toBeGreaterThanOrEqual(700);
|
||||||
|
expect(geometry.emphasisColor).toBe('rgb(0, 255, 0)');
|
||||||
|
expect(geometry.emphasisFontSize).toBe('36px');
|
||||||
|
|
||||||
|
if (artifactRoot) {
|
||||||
|
await mkdir(artifactRoot, { recursive: true });
|
||||||
|
const name = `gateway-notice-${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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
|||||||
'logout.spec.ts',
|
'logout.spec.ts',
|
||||||
'account-icon-sync.spec.ts',
|
'account-icon-sync.spec.ts',
|
||||||
'legacy-log-html.spec.ts',
|
'legacy-log-html.spec.ts',
|
||||||
|
'gateway-notice-html.spec.ts',
|
||||||
],
|
],
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
|||||||
@@ -217,11 +217,11 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<div class="max-w-5xl mx-auto py-8 px-4 space-y-8">
|
<div class="max-w-5xl mx-auto pt-24 pb-8 px-4 space-y-8">
|
||||||
<!-- Notice -->
|
<!-- Notice -->
|
||||||
<div v-if="notice" class="text-center">
|
<div v-if="notice" class="text-center">
|
||||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
<span class="text-orange-500 text-3xl font-bold" v-html="notice"></span>
|
<span data-testid="gateway-notice" class="text-orange-500 text-3xl font-bold" v-html="notice"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|||||||
Generated
+6
@@ -282,6 +282,9 @@ importers:
|
|||||||
redis:
|
redis:
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
|
sanitize-html:
|
||||||
|
specifier: 2.17.6
|
||||||
|
version: 2.17.6
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.34.4
|
specifier: ^0.34.4
|
||||||
version: 0.34.5
|
version: 0.34.5
|
||||||
@@ -289,6 +292,9 @@ importers:
|
|||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/sanitize-html':
|
||||||
|
specifier: 2.16.1
|
||||||
|
version: 2.16.1
|
||||||
tsdown:
|
tsdown:
|
||||||
specifier: ^0.18.4
|
specifier: ^0.18.4
|
||||||
version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@6.0.2)(vue-tsc@3.2.2(typescript@6.0.2))
|
version: 0.18.4(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(synckit@0.11.13)(typescript@6.0.2)(vue-tsc@3.2.2(typescript@6.0.2))
|
||||||
|
|||||||
Reference in New Issue
Block a user