From 4956164aef383eabc50ceb2491a83ad7075f9288 Mon Sep 17 00:00:00 2001 From: harry Date: Wed, 10 Jun 2026 11:33:09 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20Others=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EB=B2=94=EB=A1=80=20=EB=B0=95=EC=8A=A4=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Others 카테고리가 새로 생기며 Most Solved Categories 범례가 4개에서 5개로 늘어나 박스 하단이 빡빡해진 문제 수정. 범례 시작점을 위로 올리고 배경 박스를 키워 5개 항목의 상하 여백 균형을 맞춤. --- src/utils/generateCategorySvg.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/generateCategorySvg.ts b/src/utils/generateCategorySvg.ts index de229bb..dfe4ed4 100644 --- a/src/utils/generateCategorySvg.ts +++ b/src/utils/generateCategorySvg.ts @@ -43,8 +43,8 @@ export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light let currentAngle = 0; const legendStartX = 25; - const legendStartY = 75; - const legendItemHeight = 22; + const legendStartY = 64; + const legendItemHeight = 23; const maxLegendItems = 5; // 카테고리가 없는 경우 @@ -140,7 +140,7 @@ export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light Most Solved Categories - + From cf7eb83fc4ac45283222e2a873ca40847ec2d6c8 Mon Sep 17 00:00:00 2001 From: harry Date: Fri, 30 Oct 2026 16:55:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=ED=8F=B0?= =?UTF-8?q?=ED=8A=B8=EA=B0=80=20OS=20=EA=B8=B0=EB=B3=B8=20=ED=8F=B0?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=8C=80=EC=B2=B4=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG가 로 렌더링되면(GitHub README, 메인페이지 미리보기) 브라우저가 외부 리소스를 불러오지 않아 Google Fonts @import가 무시되고, OS 기본 monospace 폰트(macOS Chrome은 Menlo, Safari는 Courier 등)로 표시되던 문제 수정. JetBrains Mono 가변 폰트를 카드에 쓰는 굵기(400~700)와 ASCII 문자만 남겨 서브셋한 뒤 base64 @font-face로 SVG에 직접 임베드하고, 서브셋 생성 스크립트와 폰트 라이선스(OFL)를 함께 추가. Claude-Session: https://claude.ai/code/session_01AGcght9Lc8cAYdvYCrdSzd --- README.md | 2 + scripts/generate-font-subset.sh | 59 ++++++++++++ .../utils/generateCategorySvg.test.ts | 17 ++++ src/__tests__/utils/generateStatsSvg.test.ts | 9 ++ src/utils/fonts/OFL.txt | 93 +++++++++++++++++++ src/utils/fonts/jetbrainsMono.ts | 5 + src/utils/generateCategorySvg.ts | 3 +- src/utils/generateStatsSvg.ts | 3 +- 8 files changed, 189 insertions(+), 2 deletions(-) create mode 100755 scripts/generate-font-subset.sh create mode 100644 src/utils/fonts/OFL.txt create mode 100644 src/utils/fonts/jetbrainsMono.ts diff --git a/README.md b/README.md index 9d692fe..b4de073 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,5 @@ http://localhost:3000 에서 확인 ## License MIT License + +단, 카드에 임베드되는 JetBrains Mono 폰트(`src/utils/fonts/`)는 [SIL Open Font License 1.1](src/utils/fonts/OFL.txt)을 따릅니다. diff --git a/scripts/generate-font-subset.sh b/scripts/generate-font-subset.sh new file mode 100755 index 0000000..8ccc193 --- /dev/null +++ b/scripts/generate-font-subset.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# SVG 카드에 임베드할 JetBrains Mono 서브셋 폰트(src/utils/fonts/jetbrainsMono.ts)를 생성합니다. +# +# GitHub README처럼 SVG가 로 렌더링되면 브라우저가 외부 리소스(@import 등) 로드를 차단해 +# Google Fonts를 쓸 수 없고 OS 기본 monospace 폰트로 대체됩니다. +# 그래서 카드에 쓰는 글자(ASCII)와 굵기(400~700)만 남긴 가변 폰트를 base64로 SVG에 직접 넣습니다. +# +# 필요: curl, python3 + fonttools, brotli +# pip install fonttools==4.65.0 brotli==1.2.1 (버전이 다르면 생성 결과가 달라질 수 있음) +# 사용: ./scripts/generate-font-subset.sh (다른 python 사용 시 PYTHON=/path/to/python 지정) + +set -euo pipefail + +VERSION="v2.304" +BASE_URL="https://raw.githubusercontent.com/JetBrains/JetBrainsMono/$VERSION" +FONT_SHA256="662a196d58f1183bf2d77428b6d5283fe3f45161ab021bea4036bc98e5cac016" +OFL_SHA256="30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad" +UNICODES="U+0020-007E" +WEIGHT_MIN=400 +WEIGHT_MAX=700 +PYTHON="${PYTHON:-python3}" + +OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/src/utils/fonts" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +# 실행할 때마다 같은 결과가 나오도록 폰트 타임스탬프 고정 +export SOURCE_DATE_EPOCH=0 + +curl -sSfL -o "$TMP_DIR/font.ttf" "$BASE_URL/fonts/variable/JetBrainsMono%5Bwght%5D.ttf" +curl -sSfL -o "$TMP_DIR/OFL.txt" "$BASE_URL/OFL.txt" +echo "$FONT_SHA256 $TMP_DIR/font.ttf" | shasum -a 256 -c - +echo "$OFL_SHA256 $TMP_DIR/OFL.txt" | shasum -a 256 -c - + +"$PYTHON" -m fontTools.varLib.instancer "$TMP_DIR/font.ttf" "wght=$WEIGHT_MIN:$WEIGHT_MAX" \ + -o "$TMP_DIR/instanced.ttf" -q + +# 리거처 등 레이아웃 기능은 제외하고, 라이선스 정보(name ID 13, 14)는 유지 +"$PYTHON" -m fontTools.subset "$TMP_DIR/instanced.ttf" \ + --unicodes="$UNICODES" \ + --layout-features='' \ + --name-IDs=0,1,2,3,4,5,6,13,14 \ + --flavor=woff2 \ + --output-file="$TMP_DIR/subset.woff2" + +FONT_BASE64="$(base64 < "$TMP_DIR/subset.woff2" | tr -d '\n')" + +mkdir -p "$OUT_DIR" +cp "$TMP_DIR/OFL.txt" "$OUT_DIR/OFL.txt" +cat > "$OUT_DIR/jetbrainsMono.ts" < { expect(result).toContain('fill="#21262d"'); // cardBackground expect(result).toContain('stroke="#30363d"'); // border }); + + it('폰트를 외부에서 불러오지 않고 SVG에 임베드해야 함', () => { + const mockStats: TCategoryStats = { + nickname: 'weakness', + total_score: 5000, + categories: [ + { name: 'web', score: 2000, rank: 50, color: '#ff6b6b' } + ] + }; + + const result = generateCategorySvg(mockStats); + + // 로 렌더링되는 SVG는 외부 리소스(@import)를 불러올 수 없음 + expect(result).not.toContain('@import'); + expect(result).toContain("font-family: 'JetBrains Mono'"); + expect(result).toContain('url(data:font/woff2;base64,'); + }); }); \ No newline at end of file diff --git a/src/__tests__/utils/generateStatsSvg.test.ts b/src/__tests__/utils/generateStatsSvg.test.ts index 8b3b76e..15687fd 100644 --- a/src/__tests__/utils/generateStatsSvg.test.ts +++ b/src/__tests__/utils/generateStatsSvg.test.ts @@ -73,4 +73,13 @@ describe('generateStatsSvg 유틸리티 함수 테스트', () => { expect(result).toContain('fill="#21262d"'); // cardBackground expect(result).toContain('stroke="#30363d"'); // border }); + + it('폰트를 외부에서 불러오지 않고 SVG에 임베드해야 함', () => { + const result = generateStatsSvg(mockStats); + + // 로 렌더링되는 SVG는 외부 리소스(@import)를 불러올 수 없음 + expect(result).not.toContain('@import'); + expect(result).toContain("font-family: 'JetBrains Mono'"); + expect(result).toContain('url(data:font/woff2;base64,'); + }); }); \ No newline at end of file diff --git a/src/utils/fonts/OFL.txt b/src/utils/fonts/OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/src/utils/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/utils/fonts/jetbrainsMono.ts b/src/utils/fonts/jetbrainsMono.ts new file mode 100644 index 0000000..1749106 --- /dev/null +++ b/src/utils/fonts/jetbrainsMono.ts @@ -0,0 +1,5 @@ +// 이 파일은 scripts/generate-font-subset.sh 로 생성되었습니다. 직접 수정하지 마세요. +// JetBrains Mono v2.304 가변 폰트 (wght 400~700, U+0020-007E 서브셋) +// SIL Open Font License 1.1 (같은 디렉터리의 OFL.txt 참고) +export const jetBrainsMonoFontFace = + "@font-face { font-family: 'JetBrains Mono'; font-weight: 400 700; src: url(data:font/woff2;base64,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) format('woff2'); }"; diff --git a/src/utils/generateCategorySvg.ts b/src/utils/generateCategorySvg.ts index dfe4ed4..2892701 100644 --- a/src/utils/generateCategorySvg.ts +++ b/src/utils/generateCategorySvg.ts @@ -1,4 +1,5 @@ import { TCategoryStats, Theme, ThemeColors } from '../types'; +import { jetBrainsMonoFontFace } from './fonts/jetbrainsMono'; const themes: Record = { light: { @@ -123,7 +124,7 @@ export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light return ` > > ``` diff --git a/src/__tests__/pages/api/most-solved.test.ts b/src/__tests__/pages/api/most-solved.test.ts index 08e87b1..fa3f0a5 100644 --- a/src/__tests__/pages/api/most-solved.test.ts +++ b/src/__tests__/pages/api/most-solved.test.ts @@ -133,48 +133,48 @@ describe('most-solved API 엔드포인트 테스트', () => { expect(generateSvgArg.categories).toHaveLength(0); }); - it('사용자 이름이 없을 때 400 에러를 반환해야 함', async () => { - // HTTP 요청 모킹 (사용자 이름 없음) + it('사용자 이름이 없을 때 안내 카드를 반환해야 함', async () => { const { req, res } = createMocks({ method: 'GET', query: {}, }); - // API 핸들러 호출 await handler(req, res); - // 응답 검증 - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual({ error: 'Username is required' }); + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty('content-type', 'image/svg+xml'); + expect(res._getHeaders()).toHaveProperty('cache-control', 'no-cache, no-store, must-revalidate'); + expect(res._getData()).toContain('Most Solved Categories'); + expect(res._getData()).toContain('Missing username'); }); - it('사용자를 찾을 수 없을 때 400 에러를 반환해야 함', async () => { - // 모킹된 함수 구현 (사용자 ID가 null) + it('사용자를 찾을 수 없을 때 안내 카드를 반환해야 함', async () => { (dreamhackUtils.getUserId as jest.Mock).mockResolvedValueOnce(null); - // HTTP 요청 모킹 const { req, res } = createMocks({ method: 'GET', query: { username: 'nonexistentuser', + theme: 'dark', }, }); - // API 핸들러 호출 await handler(req, res); - // 응답 검증 - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual({ error: 'User not found' }); + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty('content-type', 'image/svg+xml'); + expect(res._getHeaders()).toHaveProperty('cache-control', 'no-cache, no-store, must-revalidate'); + expect(res._getData()).toContain('User not found'); + expect(res._getData()).toContain('nonexistentuser'); + // 다크 테마 배경색 적용 + expect(res._getData()).toContain('#0d1117'); }); - it('사용자 데이터를 가져올 수 없을 때 400 에러를 반환해야 함', async () => { - // 모킹된 함수 구현 + it('사용자 데이터를 가져올 수 없을 때 오류 카드를 반환해야 함', async () => { const username = 'weakness'; (dreamhackUtils.getUserId as jest.Mock).mockResolvedValueOnce(123); (dreamhackUtils.getUserData as jest.Mock).mockResolvedValueOnce(null); - // HTTP 요청 모킹 const { req, res } = createMocks({ method: 'GET', query: { @@ -182,20 +182,18 @@ describe('most-solved API 엔드포인트 테스트', () => { }, }); - // API 핸들러 호출 await handler(req, res); - // 응답 검증 - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual({ error: 'User information cannot be read.' }); + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty('content-type', 'image/svg+xml'); + expect(res._getHeaders()).toHaveProperty('cache-control', 'no-cache, no-store, must-revalidate'); + expect(res._getData()).toContain('Failed to load stats'); expect(dreamhackUtils.getUserData).toHaveBeenCalledWith(123, username); }); - it('예외 발생 시 500 에러를 반환해야 함', async () => { - // 모킹된 함수 구현 (예외 발생) + it('예외 발생 시 오류 카드를 반환해야 함', async () => { (dreamhackUtils.getUserId as jest.Mock).mockRejectedValueOnce(new Error('테스트 에러')); - // HTTP 요청 모킹 const { req, res } = createMocks({ method: 'GET', query: { @@ -203,11 +201,11 @@ describe('most-solved API 엔드포인트 테스트', () => { }, }); - // API 핸들러 호출 await handler(req, res); - // 응답 검증 - expect(res._getStatusCode()).toBe(500); - expect(JSON.parse(res._getData())).toEqual({ error: 'Internal Server Error' }); + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty('content-type', 'image/svg+xml'); + expect(res._getHeaders()).toHaveProperty('cache-control', 'no-cache, no-store, must-revalidate'); + expect(res._getData()).toContain('Failed to load stats'); }); }); \ No newline at end of file diff --git a/src/__tests__/pages/api/stats.test.ts b/src/__tests__/pages/api/stats.test.ts index 1e28941..84911a5 100644 --- a/src/__tests__/pages/api/stats.test.ts +++ b/src/__tests__/pages/api/stats.test.ts @@ -123,26 +123,23 @@ describe('stats API 엔드포인트 테스트', () => { ); }); - it('사용자 이름이 없을 때 400 에러를 반환해야 함', async () => { - // HTTP 요청 모킹 (사용자 이름 없음) + it('사용자 이름이 없을 때 안내 카드를 반환해야 함', async () => { const { req, res } = createMocks({ method: 'GET', query: {}, }); - // API 핸들러 호출 await handler(req, res); - // 응답 검증 - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual({ error: 'Username is required' }); + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty('content-type', 'image/svg+xml'); + expect(res._getHeaders()).toHaveProperty('cache-control', 'no-cache, no-store, must-revalidate'); + expect(res._getData()).toContain('Missing username'); }); - it('사용자를 찾을 수 없을 때 400 에러를 반환해야 함', async () => { - // 모킹된 함수 구현 (사용자 ID가 null) + it('사용자를 찾을 수 없을 때 안내 카드를 반환해야 함', async () => { (dreamhackUtils.getUserId as jest.Mock).mockResolvedValueOnce(null); - // HTTP 요청 모킹 const { req, res } = createMocks({ method: 'GET', query: { @@ -150,20 +147,37 @@ describe('stats API 엔드포인트 테스트', () => { }, }); - // API 핸들러 호출 await handler(req, res); - // 응답 검증 - expect(res._getStatusCode()).toBe(400); - expect(JSON.parse(res._getData())).toEqual({ error: 'User not found' }); + expect(res._getStatusCode()).toBe(200); + expect(res._getHeaders()).toHaveProperty('content-type', 'image/svg+xml'); + expect(res._getHeaders()).toHaveProperty('cache-control', 'no-cache, no-store, must-revalidate'); + expect(res._getData()).toContain('User not found'); + expect(res._getData()).toContain('nonexistentuser'); + // 사용자가 없으면 마지막 랭크는 조회하지 않음 + expect(dreamhackUtils.getLastRank).not.toHaveBeenCalled(); }); - it('마지막 랭크를 가져올 수 없을 때 500 에러를 반환해야 함', async () => { - // 모킹된 함수 구현 + it('안내 카드에 표시하는 닉네임을 이스케이프해야 함', async () => { + (dreamhackUtils.getUserId as jest.Mock).mockResolvedValueOnce(null); + + const { req, res } = createMocks({ + method: 'GET', + query: { + username: '', + }, + }); + + await handler(req, res); + + expect(res._getData()).not.toContain('', 'system("/bin/sh")']) { + const result = generateStatsSvg({ ...mockStats, nickname }); - const result = generateStatsSvg(specialStats); - - // 특수 문자가 포함된 사용자 이름이 SVG에 포함되어 있는지 확인 - expect(result).toContain('test'); + // XML로 파싱되고, 닉네임이 태그가 아닌 텍스트로 들어가야 함 + const doc = new DOMParser().parseFromString(result.trim(), 'image/svg+xml'); + expect(doc.getElementsByTagName('parsererror')).toHaveLength(0); + expect(doc.getElementsByTagName('script')).toHaveLength(0); + expect(doc.querySelector('.user-name')?.textContent).toBe(nickname); + } }); it('긴 사용자 이름을 처리할 수 있어야 함', () => { diff --git a/src/__tests__/utils/redis.test.ts b/src/__tests__/utils/redis.test.ts index 9be7f72..ce50f2b 100644 --- a/src/__tests__/utils/redis.test.ts +++ b/src/__tests__/utils/redis.test.ts @@ -1,4 +1,11 @@ -import { saveUserIdToCache, getUserIdFromCache, __setRedisClientForTesting } from '../../utils/redis'; +import { + saveUserIdToCache, + getUserIdFromCache, + deleteUserIdFromCache, + saveUserNotFoundToCache, + isUserNotFoundCached, + __setRedisClientForTesting, +} from '../../utils/redis'; import Redis from 'ioredis'; // 테스트 환경 설정 (process.env.NODE_ENV는 jest에 의해 'test'로 설정됨) @@ -11,6 +18,7 @@ jest.mock('ioredis'); const mockRedis = new Redis() as jest.Mocked; mockRedis.get = jest.fn(); mockRedis.set = jest.fn(); +mockRedis.del = jest.fn(); // 테스트용 Redis 클라이언트 설정 beforeAll(() => { @@ -31,6 +39,7 @@ describe('Redis 유틸리티 함수 테스트', () => { await saveUserIdToCache('testuser', 12345); expect(mockRedis.set).toHaveBeenCalledWith('userId:testuser', 12345); + expect(mockRedis.del).toHaveBeenCalledWith('userNotFound:testuser'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Redis에 사용자 ID 저장 완료')); }); @@ -75,4 +84,52 @@ describe('Redis 유틸리티 함수 테스트', () => { expect(console.error).toHaveBeenCalledWith('Redis에서 사용자 ID 조회 실패:', mockError); }); }); -}); \ No newline at end of file + + describe('deleteUserIdFromCache 함수', () => { + it('Redis에서 사용자 ID를 삭제해야 함', async () => { + mockRedis.del.mockResolvedValueOnce(1); + + await deleteUserIdFromCache('testuser'); + + expect(mockRedis.del).toHaveBeenCalledWith('userId:testuser'); + }); + + it('예외 발생 시 오류를 기록해야 함', async () => { + const mockError = new Error('Redis 연결 오류'); + mockRedis.del.mockRejectedValueOnce(mockError); + + await deleteUserIdFromCache('testuser'); + + expect(console.error).toHaveBeenCalledWith('Redis에서 사용자 ID 삭제 실패:', mockError); + }); + }); + + describe('미존재 사용자 캐시 함수', () => { + it('찾지 못한 닉네임을 10분 동안 저장해야 함', async () => { + mockRedis.set.mockResolvedValueOnce('OK'); + + await saveUserNotFoundToCache('nonexistentuser'); + + expect(mockRedis.set).toHaveBeenCalledWith('userNotFound:nonexistentuser', '1', 'EX', 600); + }); + + it('저장된 닉네임이면 true를 반환해야 함', async () => { + mockRedis.get.mockResolvedValueOnce('1'); + + expect(await isUserNotFoundCached('nonexistentuser')).toBe(true); + expect(mockRedis.get).toHaveBeenCalledWith('userNotFound:nonexistentuser'); + }); + + it('저장되지 않은 닉네임이면 false를 반환해야 함', async () => { + mockRedis.get.mockResolvedValueOnce(null); + + expect(await isUserNotFoundCached('weakness')).toBe(false); + }); + + it('조회 중 예외가 나면 false를 반환해야 함', async () => { + mockRedis.get.mockRejectedValueOnce(new Error('Redis 연결 오류')); + + expect(await isUserNotFoundCached('weakness')).toBe(false); + }); + }); +}); diff --git a/src/__tests__/utils/svg.test.ts b/src/__tests__/utils/svg.test.ts new file mode 100644 index 0000000..c6908a7 --- /dev/null +++ b/src/__tests__/utils/svg.test.ts @@ -0,0 +1,23 @@ +import { escapeXml } from '../../utils/svg'; + +describe('escapeXml 함수 테스트', () => { + it('XML 특수 문자를 이스케이프해야 함', () => { + expect(escapeXml(`a&bd"e'f`)).toBe('a&b<c>d"e'f'); + }); + + it('이미 이스케이프된 문자열의 &도 다시 이스케이프해야 함', () => { + expect(escapeXml('&')).toBe('&amp;'); + }); + + it('XML에서 허용되지 않는 제어 문자를 제거해야 함', () => { + expect(escapeXml('a\u0000b\u0008c\u001Fd')).toBe('abcd'); + }); + + it('탭, 줄바꿈, 한글, 이모지는 그대로 두어야 함', () => { + expect(escapeXml('꿈나라역1번출구\t🐿\n')).toBe('꿈나라역1번출구\t🐿\n'); + }); + + it('숫자도 문자열로 변환해야 함', () => { + expect(escapeXml(5000)).toBe('5000'); + }); +}); diff --git a/src/pages/api/most-solved.ts b/src/pages/api/most-solved.ts index 77b4f17..b9753b8 100644 --- a/src/pages/api/most-solved.ts +++ b/src/pages/api/most-solved.ts @@ -1,8 +1,16 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { TCategoryData, TCategoryStats, Theme } from '../../types'; import { generateCategorySvg } from '../../utils/generateCategorySvg'; +import { generateErrorSvg, errorCards } from '../../utils/generateErrorSvg'; +import { sendSvg } from '../../utils/svg'; import { getUserId, getUserData } from '../../utils/dreamhack'; +// 처음 조회하는 닉네임은 Dreamhack 검색이 여러 번 필요해 기본 10초를 넘을 수 있음 +// (요청당 타임아웃 10초 × 최대 5단계 + 여유, Hobby 플랜 최대값) +export const config = { maxDuration: 60 }; + +const CARD_TITLE = 'Most Solved Categories'; + // 성능 측정을 위한 유틸리티 함수 const measureTime = async (name: string, fn: () => Promise): Promise => { console.time(`⏱️ ${name}`); @@ -30,30 +38,30 @@ const defaultColors = [ ]; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - console.time('⏱️ 전체 API 실행 시간'); const { username, theme: themeParam } = req.query; - if (!username || typeof username !== 'string') { - return res.status(400).json({ error: 'Username is required' }); - } - // 테마 파라미터 검증 const theme: Theme = themeParam === 'dark' ? 'dark' : 'light'; + if (!username || typeof username !== 'string') { + return sendSvg(res, generateErrorSvg(errorCards.missingUsername(CARD_TITLE), theme)); + } + + console.time('⏱️ 전체 API 실행 시간'); try { // 개별 API 호출 시간 측정 - const userId = await measureTime('getUserId', () => getUserId(username as string)); + const userId = await measureTime('getUserId', () => getUserId(username)); if (!userId) { console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.status(400).json({ error: 'User not found' }); + return sendSvg(res, generateErrorSvg(errorCards.userNotFound(CARD_TITLE, username), theme)); } // username을 함께 전달하여 캐시된 ID가 유효하지 않을 경우 재시도할 수 있도록 함 - const userData = await measureTime('getUserData', () => getUserData(userId, username as string)); + const userData = await measureTime('getUserData', () => getUserData(userId, username)); if (!userData) { console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.status(400).json({ error: 'User information cannot be read.' }); + return sendSvg(res, generateErrorSvg(errorCards.fetchFailed(CARD_TITLE), theme)); } console.time('⏱️ 데이터 가공 및 SVG 생성'); @@ -74,10 +82,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) }; const svg = generateCategorySvg(emptyStats, theme); - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + console.timeEnd('⏱️ 데이터 가공 및 SVG 생성'); console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.send(svg); + return sendSvg(res, svg); } // 카테고리 데이터가 있는 경우 가공 @@ -105,13 +112,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const svg = generateCategorySvg(categoryStats, theme); console.timeEnd('⏱️ 데이터 가공 및 SVG 생성'); - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.send(svg); + return sendSvg(res, svg); } catch (error) { console.error(error); console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.status(500).json({ error: 'Internal Server Error' }); + return sendSvg(res, generateErrorSvg(errorCards.fetchFailed(CARD_TITLE), theme)); } } \ No newline at end of file diff --git a/src/pages/api/stats.ts b/src/pages/api/stats.ts index fe81df1..4039b56 100644 --- a/src/pages/api/stats.ts +++ b/src/pages/api/stats.ts @@ -1,6 +1,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { Tstats, Theme } from '../../types'; import { generateStatsSvg } from '../../utils/generateStatsSvg'; +import { generateErrorSvg, errorCards } from '../../utils/generateErrorSvg'; +import { sendSvg } from '../../utils/svg'; import { getLastRank, calculateTopPercentage, @@ -8,6 +10,12 @@ import { getUserData } from '../../utils/dreamhack'; +// 처음 조회하는 닉네임은 Dreamhack 검색이 여러 번 필요해 기본 10초를 넘을 수 있음 +// (요청당 타임아웃 10초 × 최대 5단계 + 여유, Hobby 플랜 최대값) +export const config = { maxDuration: 60 }; + +const CARD_TITLE = 'Dreamhack Wargame Stats'; + // 성능 측정을 위한 유틸리티 함수 const measureTime = async (name: string, fn: () => Promise): Promise => { console.time(`⏱️ ${name}`); @@ -19,35 +27,35 @@ const measureTime = async (name: string, fn: () => Promise): Promise => }; export default async function handler(req: NextApiRequest, res: NextApiResponse) { - console.time('⏱️ 전체 API 실행 시간'); const { username, theme: themeParam } = req.query; - if (!username || typeof username !== 'string') { - return res.status(400).json({ error: 'Username is required' }); - } - // 테마 파라미터 검증 const theme: Theme = themeParam === 'dark' ? 'dark' : 'light'; + if (!username || typeof username !== 'string') { + return sendSvg(res, generateErrorSvg(errorCards.missingUsername(CARD_TITLE), theme)); + } + + console.time('⏱️ 전체 API 실행 시간'); try { // 개별 API 호출 시간 측정 - const userId = await measureTime('getUserId', () => getUserId(username as string)); - const lastRank = await measureTime('getLastRank', () => getLastRank()); - + const userId = await measureTime('getUserId', () => getUserId(username)); if (!userId) { console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.status(400).json({ error: 'User not found' }); + return sendSvg(res, generateErrorSvg(errorCards.userNotFound(CARD_TITLE, username), theme)); } + + const lastRank = await measureTime('getLastRank', () => getLastRank()); if (!lastRank) { console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.status(500).json({ error: 'Failed to fetch last rank' }); + return sendSvg(res, generateErrorSvg(errorCards.fetchFailed(CARD_TITLE), theme)); } // username을 함께 전달하여 캐시된 ID가 유효하지 않을 경우 재시도할 수 있도록 함 - const userData = await measureTime('getUserData', () => getUserData(userId, username as string)); + const userData = await measureTime('getUserData', () => getUserData(userId, username)); if (!userData) { console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.status(400).json({ error: 'User information cannot be read.' }); + return sendSvg(res, generateErrorSvg(errorCards.fetchFailed(CARD_TITLE), theme)); } console.time('⏱️ 데이터 가공 및 SVG 생성'); @@ -70,13 +78,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const svg = generateStatsSvg(stats, theme); console.timeEnd('⏱️ 데이터 가공 및 SVG 생성'); - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.send(svg); + return sendSvg(res, svg); } catch (error) { console.error(error); console.timeEnd('⏱️ 전체 API 실행 시간'); - return res.status(500).json({ error: 'Internal Server Error' }); + return sendSvg(res, generateErrorSvg(errorCards.fetchFailed(CARD_TITLE), theme)); } -} \ No newline at end of file +} diff --git a/src/pages/api/user-id.ts b/src/pages/api/user-id.ts new file mode 100644 index 0000000..de5a45d --- /dev/null +++ b/src/pages/api/user-id.ts @@ -0,0 +1,30 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { getUserId } from '../../utils/dreamhack'; + +// 처음 조회하는 닉네임은 Dreamhack 검색이 여러 번 필요해 기본 10초를 넘을 수 있음 +// (요청당 타임아웃 10초 × 최대 5단계 + 여유, Hobby 플랜 최대값) +export const config = { maxDuration: 60 }; + +// 메인 페이지에서 프로필 링크(숫자 ID 주소)를 만들 때 사용 +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { username } = req.query; + + if (!username || typeof username !== 'string') { + return res.status(400).json({ error: 'Username is required' }); + } + + try { + const userId = await getUserId(username); + if (!userId) { + return res.status(404).json({ error: 'User not found' }); + } + + return res.status(200).json({ + id: userId, + profileUrl: `https://dreamhack.io/users/${userId}`, + }); + } catch (error) { + console.error('Error fetching user id:', error); + return res.status(500).json({ error: 'Internal Server Error' }); + } +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 0272762..3ad1202 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import Head from 'next/head'; import styles from '../styles/Home.module.css'; +import { encodeUsername } from '../utils/encodeUsername'; type Theme = 'light' | 'dark'; type CardType = 'stats' | 'most-solved'; @@ -77,6 +78,21 @@ export default function Home() { const [selectedCard, setSelectedCard] = useState('stats'); const [previewKey, setPreviewKey] = useState(0); const [isLoading, setIsLoading] = useState(false); + // 닉네임별 프로필 주소 (찾지 못하면 null). 늦게 도착한 다른 닉네임의 응답이 현재 결과를 덮어쓰지 않도록 닉네임별로 보관 + const [profileUrls, setProfileUrls] = useState>(new Map()); + const profileUrl = profileUrls.get(confirmedUsername) ?? null; + + // Dreamhack 프로필 주소는 닉네임이 아닌 숫자 ID를 사용하므로 미리보기한 닉네임의 ID를 조회 + const loadProfileUrl = (user: string) => { + fetch(`/api/user-id?username=${encodeUsername(user)}`) + .then((response) => { + if (response.status === 404) return null; + if (!response.ok) throw new Error(`user-id ${response.status}`); + return response.json(); + }) + .then((data) => setProfileUrls((prev) => new Map(prev).set(user, data?.profileUrl ?? null))) + .catch(() => {}); + }; const cardOptions: DropdownOption[] = [ { value: 'stats', label: 'Wargame Stats' }, @@ -92,13 +108,13 @@ export default function Home() { const getApiUrl = (card: CardType, theme: Theme, user: string) => { const endpoint = card === 'stats' ? 'stats' : 'most-solved'; - return `${baseUrl}/api/${endpoint}?username=${user || '사용자명'}&theme=${theme}`; + return `${baseUrl}/api/${endpoint}?username=${user ? encodeUsername(user) : '사용자명'}&theme=${theme}`; }; const getPreviewUrl = (card: CardType, theme: Theme, user: string) => { if (!user) return ''; const endpoint = card === 'stats' ? 'stats' : 'most-solved'; - return `/api/${endpoint}?username=${user}&theme=${theme}`; + return `/api/${endpoint}?username=${encodeUsername(user)}&theme=${theme}`; }; const getMarkdownCode = () => { @@ -108,8 +124,7 @@ export default function Home() { const getHtmlCode = () => { const altText = selectedCard === 'stats' ? 'Dreamhack Stats' : 'Dreamhack Category Chart'; - const user = confirmedUsername || '사용자명'; - return ` + return ` ${altText} `; }; @@ -127,13 +142,17 @@ export default function Home() { alert('사용자명을 입력해주세요.'); return; } - setConfirmedUsername(username); + setConfirmedUsername(username.trim()); setIsLoading(true); setPreviewKey(prev => prev + 1); }; const handleImageLoad = () => { setIsLoading(false); + // 카드 요청이 서버 캐시를 채운 뒤에 조회해야 Dreamhack 검색이 중복되지 않음 + if (!profileUrls.has(confirmedUsername)) { + loadProfileUrl(confirmedUsername); + } }; const handleImageError = () => { @@ -248,6 +267,11 @@ export default function Home() { {getHtmlCode()} + {!profileUrl && ( +

+ 사용자ID는 Dreamhack 프로필 주소(https://dreamhack.io/users/12345)의 숫자입니다. 미리보기에 성공하면 자동으로 채워집니다. +

+ )} diff --git a/src/styles/Home.module.css b/src/styles/Home.module.css index 01b0b0a..76588a7 100644 --- a/src/styles/Home.module.css +++ b/src/styles/Home.module.css @@ -87,6 +87,12 @@ line-height: 1.5; } +.codeHint { + margin: 0.5rem 0 0 0; + font-size: 0.8rem; + color: #666; +} + .example { margin: 0 0 2rem 0; text-align: center; diff --git a/src/types/index.ts b/src/types/index.ts index dab1400..364d82a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -54,6 +54,7 @@ export interface TgetLastRankResponse { } export interface TUserRankingResponse { + count: number; results: TUserRankingResult[]; } @@ -77,6 +78,12 @@ export interface TCategoryStats { export type Theme = 'light' | 'dark'; +export interface TErrorCard { + title: string; + message: string; + details: string[]; +} + export interface ThemeColors { background: string; cardBackground: string; diff --git a/src/utils/dreamhack.ts b/src/utils/dreamhack.ts index 8a79526..711ad86 100644 --- a/src/utils/dreamhack.ts +++ b/src/utils/dreamhack.ts @@ -1,17 +1,57 @@ import { TgetLastRankResponse, TUserRankingResponse, TuserData, TUserRankingResult } from '../types'; -import { getUserIdFromCache, saveUserIdToCache } from './redis'; +import { + getUserIdFromCache, + saveUserIdToCache, + deleteUserIdFromCache, + isUserNotFoundCached, + saveUserNotFoundToCache, +} from './redis'; + +// Dreamhack이 응답하지 않을 때 요청 하나를 기다리는 최대 시간 +const FETCH_TIMEOUT_MS = 10000; +// 랭킹 API는 limit을 100으로 보내도 50개까지만 돌려줌 +const SEARCH_PAGE_SIZE = 50; +// 검색 결과는 랭킹 순이라, 흔한 문자열이 들어간 닉네임은 뒤쪽 페이지에 있을 수 있음 (최대 250명 확인) +const SEARCH_MAX_PAGES = 5; +// 실제 닉네임은 이보다 훨씬 짧음 +const MAX_USERNAME_LENGTH = 64; // 네트워크 요청 시간 측정을 위한 래퍼 함수 async function measureFetch(url: string, name: string): Promise { console.time(`🌐 ${name} 네트워크 요청`); try { - const response = await fetch(url); + const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); return response; } finally { console.timeEnd(`🌐 ${name} 네트워크 요청`); } } +// 랭킹 검색 한 페이지 조회 (닉네임의 &, #, + 등이 쿼리를 깨뜨리지 않도록 인코딩) +async function searchRanking(username: string, offset: number): Promise { + const params = new URLSearchParams({ + filter: 'global', + limit: String(SEARCH_PAGE_SIZE), + offset: String(offset), + search: username, + scope: 'all', + name: '', + category: '', + }); + const response = await measureFetch( + `https://dreamhack.io/api/v1/ranking/wargame/?${params}`, + `getUserId(offset=${offset})` + ); + if (!response.ok) { + throw new Error(`Failed to search ranking: ${response.status}`); + } + return await response.json() as TUserRankingResponse; +} + +function findExactUser(results: TUserRankingResult[], username: string): TUserRankingResult | undefined { + return results.find((user) => user.nickname === username); +} + export async function getLastRank(): Promise { try { console.time('⚙️ getLastRank 함수 전체'); @@ -33,7 +73,13 @@ export async function getLastRank(): Promise { } } +// 사용자를 찾지 못하면 null, 검색 자체가 실패하면 예외를 던짐 (오류 카드에서 둘을 구분하기 위함) export async function getUserId(username: string): Promise { + // 실제 닉네임일 수 없는 긴 값은 Redis 키를 만들거나 Dreamhack을 검색하지 않음 + if (Array.from(username).length > MAX_USERNAME_LENGTH) { + return null; + } + try { console.time('⚙️ getUserId 함수 전체'); @@ -45,33 +91,50 @@ export async function getUserId(username: string): Promise { return cachedUserId; } + // 최근 검색에서 찾지 못한 닉네임이면 Dreamhack 검색을 반복하지 않음 + if (await isUserNotFoundCached(username)) { + console.log(`🚫 최근 검색에서 찾지 못한 사용자입니다: ${username}`); + console.timeEnd('⚙️ getUserId 함수 전체'); + return null; + } + console.log(`🔍 Redis 캐시에서 사용자 ID를 찾지 못했습니다. API 호출을 시도합니다: ${username}`); - const response = await measureFetch( - `https://dreamhack.io/api/v1/ranking/wargame/?filter=global&limit=100&offset=0&search=${username}&scope=all&name=&category=`, - 'getUserId' - ); - - console.time('⚙️ getUserId JSON 파싱'); - const data = await response.json() as TUserRankingResponse; - console.timeEnd('⚙️ getUserId JSON 파싱'); - - console.time('⚙️ getUserId 사용자 검색'); - const user = data.results.find((user: TUserRankingResult) => user.nickname === username); - console.timeEnd('⚙️ getUserId 사용자 검색'); - + const firstPage = await searchRanking(username, 0); + let user = findExactUser(firstPage.results, username); + + // 첫 페이지에 없고 검색 결과가 더 있으면 다음 페이지들을 동시에 확인 + if (!user && firstPage.count > SEARCH_PAGE_SIZE) { + const lastOffset = Math.min(firstPage.count, SEARCH_PAGE_SIZE * SEARCH_MAX_PAGES); + const offsets: number[] = []; + for (let offset = SEARCH_PAGE_SIZE; offset < lastOffset; offset += SEARCH_PAGE_SIZE) { + offsets.push(offset); + } + // 일부 페이지가 실패해도 성공한 페이지에서 찾으면 사용 + const pages = await Promise.allSettled(offsets.map((offset) => searchRanking(username, offset))); + const results = pages.flatMap((page) => (page.status === 'fulfilled' ? page.value.results : [])); + user = findExactUser(results, username); + + // 못 찾았는데 실패한 페이지가 있으면 없는 사용자라고 단정할 수 없음 + if (!user && pages.some((page) => page.status === 'rejected')) { + throw new Error('Failed to search some ranking pages'); + } + } + const userId = user ? user.id : null; - - // 사용자 ID를 찾았다면 Redis에 저장 + + // 찾았으면 ID를, 못 찾았으면 미존재 사실을 Redis에 저장 (검색 자체가 실패한 경우는 저장하지 않음) if (userId !== null) { await saveUserIdToCache(username, userId); + } else { + await saveUserNotFoundToCache(username); } - + console.timeEnd('⚙️ getUserId 함수 전체'); return userId; } catch (error) { console.error('Error fetching user ID:', error); console.timeEnd('⚙️ getUserId 함수 전체'); - return null; + throw error; } } @@ -89,10 +152,11 @@ export async function getUserData(userId: number, username?: string): Promise ${newUserId}`); // 새로운 사용자 ID로 재귀적으로 다시 호출 return getUserData(newUserId); diff --git a/src/utils/encodeUsername.ts b/src/utils/encodeUsername.ts new file mode 100644 index 0000000..584a905 --- /dev/null +++ b/src/utils/encodeUsername.ts @@ -0,0 +1,8 @@ +// 카드 URL의 username 값 인코딩 +// &, #, +, 공백, 괄호 등 URL·Markdown을 깨뜨리는 ASCII 문자만 인코딩하고, 한글 등은 README에서 읽기 쉽게 그대로 둠 +export function encodeUsername(username: string): string { + return username.replace( + /[^A-Za-z0-9\-_.~\u0080-￿]/g, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}` + ); +} diff --git a/src/utils/generateCategorySvg.ts b/src/utils/generateCategorySvg.ts index 2892701..8855187 100644 --- a/src/utils/generateCategorySvg.ts +++ b/src/utils/generateCategorySvg.ts @@ -1,27 +1,7 @@ -import { TCategoryStats, Theme, ThemeColors } from '../types'; +import { TCategoryStats, Theme } from '../types'; +import { escapeXml, themes } from './svg'; import { jetBrainsMonoFontFace } from './fonts/jetbrainsMono'; -const themes: Record = { - light: { - background: '#ffffff', - cardBackground: '#f8fafc', - border: '#e2e8f0', - title: '#64748b', - text: '#0f172a', - subText: '#94a3b8', - accent: '#3b82f6', - }, - dark: { - background: '#0d1117', - cardBackground: '#21262d', - border: '#30363d', - title: '#8b949e', - text: '#e6edf3', - subText: '#7d8590', - accent: '#58a6ff', - }, -}; - export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light'): string { const colors = themes[theme]; // SVG 크기 및 차트 설정 @@ -107,7 +87,7 @@ export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light const capitalizedName = category.name.charAt(0).toUpperCase() + category.name.slice(1); legends += ` - ${capitalizedName} + ${escapeXml(capitalizedName)} ${percentage}% `; }); @@ -117,7 +97,7 @@ export function generateCategorySvg(stats: TCategoryStats, theme: Theme = 'light const centerCircle = ` Total - ${totalScore} + ${escapeXml(totalScore)} `; // SVG 구조 반환 diff --git a/src/utils/generateErrorSvg.ts b/src/utils/generateErrorSvg.ts new file mode 100644 index 0000000..0ab106d --- /dev/null +++ b/src/utils/generateErrorSvg.ts @@ -0,0 +1,66 @@ +import { TErrorCard, Theme } from '../types'; +import { escapeXml, themes } from './svg'; +import { jetBrainsMonoFontFace } from './fonts/jetbrainsMono'; + +// 카드 폭(390px)을 넘지 않도록 닉네임 표시 길이 제한 +const MAX_NICKNAME_LENGTH = 30; + +function truncate(value: string, maxLength: number): string { + const chars = Array.from(value); + return chars.length > maxLength ? `${chars.slice(0, maxLength - 3).join('')}...` : value; +} + +// README에서 깨진 이미지 대신 보여줄 오류 카드 문구 +export const errorCards = { + missingUsername: (title: string): TErrorCard => ({ + title, + message: 'Missing username', + details: ['Add ?username= to the card URL.'], + }), + userNotFound: (title: string, username: string): TErrorCard => ({ + title, + message: 'User not found', + details: [ + `Nickname: ${truncate(username, MAX_NICKNAME_LENGTH)}`, + 'Nicknames are case-sensitive.', + 'Only users on the wargame ranking are shown.', + ], + }), + fetchFailed: (title: string): TErrorCard => ({ + title, + message: 'Failed to load stats', + details: ['Could not load data from Dreamhack.', 'Please try again later.'], + }), +}; + +export function generateErrorSvg(error: TErrorCard, theme: Theme = 'light'): string { + const colors = themes[theme]; + + const details = error.details + .map((line, index) => `${escapeXml(line)}`) + .join('\n '); + + return ` + + + + + + + + ${escapeXml(error.title)} + + + ${escapeXml(error.message)} + ${details} + + + + + `; +} diff --git a/src/utils/generateStatsSvg.ts b/src/utils/generateStatsSvg.ts index cd3a6f3..07fbb34 100644 --- a/src/utils/generateStatsSvg.ts +++ b/src/utils/generateStatsSvg.ts @@ -1,34 +1,14 @@ -import { Tstats, Theme, ThemeColors } from '../types'; +import { Tstats, Theme } from '../types'; +import { escapeXml, themes } from './svg'; import { jetBrainsMonoFontFace } from './fonts/jetbrainsMono'; -const themes: Record = { - light: { - background: '#ffffff', - cardBackground: '#f8fafc', - border: '#e2e8f0', - title: '#64748b', - text: '#0f172a', - subText: '#94a3b8', - accent: '#3b82f6', - }, - dark: { - background: '#0d1117', - cardBackground: '#21262d', - border: '#30363d', - title: '#8b949e', - text: '#e6edf3', - subText: '#7d8590', - accent: '#58a6ff', - }, -}; - export function generateStatsSvg(stats: Tstats, theme: Theme = 'light'): string { const colors = themes[theme]; const rankParts = stats.wargame_rank.split('/'); const rankDisplay = rankParts.length === 2 - ? `${rankParts[0]}/${rankParts[1]}` - : `${stats.wargame_rank}`; + ? `${escapeXml(rankParts[0])}/${escapeXml(rankParts[1])}` + : `${escapeXml(stats.wargame_rank)}`; return ` @@ -50,19 +30,19 @@ export function generateStatsSvg(stats: Tstats, theme: Theme = 'light'): string Dreamhack Wargame Stats - ${stats.nickname} + ${escapeXml(stats.nickname)} TOP - ${stats.wargameRankPercentage}% + ${escapeXml(stats.wargameRankPercentage)}% Solved Challenges - ${stats.wargame_solved} + ${escapeXml(stats.wargame_solved)} @@ -74,7 +54,7 @@ export function generateStatsSvg(stats: Tstats, theme: Theme = 'light'): string Score - ${stats.wargame_score} + ${escapeXml(stats.wargame_score)} diff --git a/src/utils/redis.ts b/src/utils/redis.ts index 58c6e6a..5fa236a 100644 --- a/src/utils/redis.ts +++ b/src/utils/redis.ts @@ -74,6 +74,8 @@ export async function saveUserIdToCache(username: string, userId: number): Promi try { await redis.set(`userId:${username}`, userId); + // 동시에 실행된 다른 검색이 남긴 미존재 기록이 404 재시도를 막지 않도록 함께 삭제 + await redis.del(`userNotFound:${username}`); console.log(`✅ Redis에 사용자 ID 저장 완료: ${username} -> ${userId}`); } catch (error) { console.error('Redis에 사용자 ID 저장 실패:', error); @@ -100,6 +102,43 @@ export async function getUserIdFromCache(username: string): Promise { + if (!redis) return; + + try { + await redis.del(`userId:${username}`); + console.log(`🗑️ Redis에서 사용자 ID 삭제 완료: ${username}`); + } catch (error) { + console.error('Redis에서 사용자 ID 삭제 실패:', error); + } +} + +// 찾지 못한 닉네임을 잠시 기록해, README가 조회될 때마다 느린 Dreamhack 검색을 반복하지 않도록 함 +const USER_NOT_FOUND_TTL_SECONDS = 600; + +export async function saveUserNotFoundToCache(username: string): Promise { + if (!redis) return; + + try { + await redis.set(`userNotFound:${username}`, '1', 'EX', USER_NOT_FOUND_TTL_SECONDS); + console.log(`✅ Redis에 미존재 사용자 저장 완료: ${username}`); + } catch (error) { + console.error('Redis에 미존재 사용자 저장 실패:', error); + } +} + +export async function isUserNotFoundCached(username: string): Promise { + if (!redis) return false; + + try { + return (await redis.get(`userNotFound:${username}`)) !== null; + } catch (error) { + console.error('Redis에서 미존재 사용자 조회 실패:', error); + return false; + } +} + // 테스트를 위한 Redis 클라이언트 설정 함수 export function __setRedisClientForTesting(client: Redis | null): void { if (isTestEnvironment) { diff --git a/src/utils/svg.ts b/src/utils/svg.ts new file mode 100644 index 0000000..c7a5b17 --- /dev/null +++ b/src/utils/svg.ts @@ -0,0 +1,42 @@ +import type { NextApiResponse } from 'next'; +import { Theme, ThemeColors } from '../types'; + +export const themes: Record = { + light: { + background: '#ffffff', + cardBackground: '#f8fafc', + border: '#e2e8f0', + title: '#64748b', + text: '#0f172a', + subText: '#94a3b8', + accent: '#3b82f6', + }, + dark: { + background: '#0d1117', + cardBackground: '#21262d', + border: '#30363d', + title: '#8b949e', + text: '#e6edf3', + subText: '#7d8590', + accent: '#58a6ff', + }, +}; + +// SVG(XML)에 넣는 문자열의 특수문자를 이스케이프 (닉네임에 &, < 등이 있으면 SVG가 깨지거나 태그로 해석됨) +// XML에서 허용되지 않는 제어 문자는 제거 +export function escapeXml(value: string | number): string { + return String(value) + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F￾￿]/g, '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// SVG 카드 응답 (GitHub camo는 200이 아닌 응답을 이미지로 보여주지 않으므로 오류 카드도 200으로 응답) +export function sendSvg(res: NextApiResponse, svg: string) { + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + return res.status(200).send(svg); +}