-
Notifications
You must be signed in to change notification settings - Fork 0
정적 좌표 지옥에서 탈출한 동적 별자리 노드 시스템 구축기 #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8ad1be6
1c43bad
983ecd1
808f1bd
279fc9b
4b62a88
90f4e8d
bfdcfd9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -52,11 +52,36 @@ function convertImgToPicture( | |||||||||||||||||
| additionalAttrs: string = "", | ||||||||||||||||||
| ): string { | ||||||||||||||||||
| const safeAlt = alt || ""; | ||||||||||||||||||
| const safeAttrs = additionalAttrs && additionalAttrs.trim() ? " " + additionalAttrs.trim() : ""; | ||||||||||||||||||
| let safeAttrs = additionalAttrs && additionalAttrs.trim() ? " " + additionalAttrs.trim() : ""; | ||||||||||||||||||
| let pictureStyle = ""; | ||||||||||||||||||
|
|
||||||||||||||||||
| // width 속성 처리: 퍼센트나 단위가 있는 경우 picture의 스타일로 이동하여 중첩 계산 방지 | ||||||||||||||||||
| const widthMatch = /width=["']([^"']+)["']/i.exec(safeAttrs); | ||||||||||||||||||
| if (widthMatch) { | ||||||||||||||||||
| const widthVal = widthMatch[1]; | ||||||||||||||||||
| if (widthVal.includes("%") || widthVal.includes("px")) { | ||||||||||||||||||
| pictureStyle = `width: ${widthVal}; display: inline-block;`; | ||||||||||||||||||
| // img 태그에서는 원본 width 속성을 제거하고 내부적으로 100%를 가지도록 함 | ||||||||||||||||||
| safeAttrs = safeAttrs.replace(/width=["'][^"']*["']/gi, "").trim(); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (safeAttrs.includes("style=\"")) { | ||||||||||||||||||
| safeAttrs = safeAttrs.replace(/style=["']([^"']*)["']/i, (m, s) => { | ||||||||||||||||||
| const baseStyle = s.trim(); | ||||||||||||||||||
| const separator = baseStyle && !baseStyle.endsWith(";") ? ";" : ""; | ||||||||||||||||||
| return `style="${baseStyle}${separator} width: 100%;"`; | ||||||||||||||||||
| }); | ||||||||||||||||||
| } else { | ||||||||||||||||||
| safeAttrs += " style=\"width: 100%;\""; | ||||||||||||||||||
| } | ||||||||||||||||||
| } else { | ||||||||||||||||||
| // 숫자만 있는 경우(HTML5 표준) px 제거 로직 유지 | ||||||||||||||||||
| safeAttrs = safeAttrs.replace(/width="(\d+)(px)?"/gi, "width=\"$1\""); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // 외부 URL이거나 svg, gif는 picture 태그로 변환하지 않음 | ||||||||||||||||||
| if (src.startsWith("http") || src.endsWith(".svg") || src.endsWith(".gif")) { | ||||||||||||||||||
| return `<img src="${src}" alt="${safeAlt}"${safeAttrs} loading="lazy" />`; | ||||||||||||||||||
| return `<img src="${src}" alt="${safeAlt}"${safeAttrs ? " " + safeAttrs : ""} loading="lazy" />`; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // 이미지 포맷별 경로 생성 | ||||||||||||||||||
|
|
@@ -82,13 +107,14 @@ function convertImgToPicture( | |||||||||||||||||
|
|
||||||||||||||||||
| // 변환된 이미지가 없으면 원본만 사용 | ||||||||||||||||||
| if (sources.length === 0) { | ||||||||||||||||||
| return `<img src="${src}" alt="${safeAlt}"${safeAttrs} loading="lazy" />`; | ||||||||||||||||||
| return `<img src="${src}" alt="${safeAlt}"${safeAttrs ? " " + safeAttrs : ""} loading="lazy" />`; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // picture 태그 생성 (원본을 최종 fallback으로 사용) | ||||||||||||||||||
| return `<picture> | ||||||||||||||||||
| const pictureAttr = pictureStyle ? ` style="${pictureStyle}"` : ""; | ||||||||||||||||||
| return `<picture${pictureAttr}> | ||||||||||||||||||
|
Comment on lines
+114
to
+115
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||||||||||||||||||
| ${sources.join("\n ")} | ||||||||||||||||||
| <img src="${src}" alt="${safeAlt}"${safeAttrs} loading="lazy" /> | ||||||||||||||||||
| <img src="${src}" alt="${safeAlt}"${safeAttrs ? " " + safeAttrs : ""} loading="lazy" /> | ||||||||||||||||||
| </picture>`; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -117,6 +143,7 @@ function processHtmlImages(html: string, markdownPath: string): string { | |||||||||||||||||
| .replace(/src=["'][^"']*["']/gi, "") | ||||||||||||||||||
| .replace(/alt=["'][^"']*["']/gi, "") | ||||||||||||||||||
| .replace(/loading=["'][^"']*["']/gi, "") // loading은 우리가 추가할 것이므로 제거 | ||||||||||||||||||
| .replace(/\s+/g, " ") // 중복 공백 제거 | ||||||||||||||||||
| .trim(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 속성이 비어있거나 공백만 있으면 빈 문자열로 | ||||||||||||||||||
|
|
@@ -142,16 +169,23 @@ export function markdownPicturePlugin(md: MarkdownIt) { | |||||||||||||||||
| md.renderer.rules.image = (tokens, idx, options, env, self) => { | ||||||||||||||||||
| const token = tokens[idx]; | ||||||||||||||||||
| const srcIndex = token.attrIndex("src"); | ||||||||||||||||||
| const altIndex = token.attrIndex("alt"); | ||||||||||||||||||
|
|
||||||||||||||||||
| if (srcIndex < 0) { | ||||||||||||||||||
| return defaultRender(tokens, idx, options, env, self); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const src = token.attrs![srcIndex][1]; | ||||||||||||||||||
| const alt = token.content; | ||||||||||||||||||
| const alt = altIndex >= 0 ? token.attrs![altIndex][1] : token.content || ""; | ||||||||||||||||||
| const markdownPath = env.path || ""; | ||||||||||||||||||
|
|
||||||||||||||||||
| return convertImgToPicture(src, alt, markdownPath); | ||||||||||||||||||
| // src, alt를 제외한 나머지 속성들 추출 (width, height 등) | ||||||||||||||||||
| const additionalAttrs = token.attrs! | ||||||||||||||||||
| .filter(([name]) => name !== "src" && name !== "alt") | ||||||||||||||||||
| .map(([name, value]) => `${name}="${value}"`) | ||||||||||||||||||
| .join(" "); | ||||||||||||||||||
|
Comment on lines
+183
to
+186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Attributes extracted from the markdown token are concatenated into a string without escaping their values. This allows an attacker to inject arbitrary HTML attributes or break out of the attribute context. All attribute values should be escaped before being included in the
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| return convertImgToPicture(src, alt, markdownPath, additionalAttrs); | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| // 2. HTML inline 이미지 (<img>) 처리 | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| # ToothlessDev Blog | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 이 프로젝트는 VitePress를 기반으로 구축된 기술 블로그 및 포트폴리오 사이트입니다. | ||
|
|
||
| ## 프로젝트 개요 | ||
| - **프레임워크:** [VitePress](https://vitepress.dev/) (Vue 기반 정적 사이트 생성기) | ||
| - **주요 기술:** TypeScript, Vue 3, Vanilla CSS | ||
| - **콘텐츠 관리:** `contents/` 디렉토리 내의 마크다운 파일 | ||
| - **주요 기능:** | ||
| - 이미지 자동 최적화 (Sharp 사용) | ||
| - KaTeX를 이용한 수식 렌더링 | ||
| - Giscus를 이용한 댓글 시스템 | ||
| - 포스트 목록 및 사이드바 자동 생성 플러그인 | ||
|
|
||
| ## 디렉토리 구조 | ||
| - `.vitepress/`: VitePress 설정 및 커스텀 플러그인 (`plugins/`) | ||
| - `contents/`: 마크다운 콘텐츠 | ||
| - `posts/`: 기술 블로그 포스트 | ||
| - `projects/`: 프로젝트 소개 | ||
| - `archive/`: 아카이브 페이지 | ||
| - `src/`: 테마 커스터마이징을 위한 Vue 컴포넌트 및 유틸리티 | ||
| - `scripts/`: 포스트 데이터 생성을 위한 스크립트 | ||
| - `data/`: 자동 생성된 포스트 데이터 (`posts.json`) | ||
|
|
||
| ## 개발 및 빌드 명령 | ||
| - **의존성 설치:** `yarn install` | ||
| - **로컬 개발 서버 실행:** `npm run docs:dev` | ||
| - **프로젝트 빌드:** `npm run docs:build` | ||
| - **빌드 결과물 미리보기:** `npm run docs:preview` | ||
| - **포스트 데이터 수동 생성:** `npm run generate-posts` | ||
|
|
||
| ## 개발 컨벤션 | ||
| ### 포스트 작성 (Markdown) | ||
| 모든 포스트는 `contents/posts/` 하위 디렉토리에 위치해야 하며, 다음의 frontmatter 형식을 반드시 준수해야 합니다: | ||
| ```yaml | ||
| --- | ||
| title: "포스트 제목" | ||
| description: "포스트 요약 설명" | ||
| createdAt: "2024-03-20" | ||
| category: "카테고리명" | ||
| comment: true # 댓글 활성화 여부 | ||
| --- | ||
| ``` | ||
| - `index.md` 파일은 포스트 목록 생성에서 제외됩니다. | ||
| - 이미지는 각 포스트 디렉토리 내의 `img/` 폴더에 위치시키는 것을 권장합니다. | ||
|
|
||
| ### 스타일링 | ||
| - 컴포넌트별로 전용 `.css` 파일을 생성하여 관리합니다 (`src/components/` 참조). | ||
| - 전역 스타일은 `.vitepress/theme/style.css`에서 관리합니다. | ||
|
|
||
| ### 아키텍처 및 플러그인 | ||
| - **플러그인 시스템:** `.vitepress/plugins/`에 위치한 커스텀 Vite 플러그인을 통해 빌드 시 포스트 목록 생성, 이미지 최적화 등을 자동화합니다. | ||
| - **데이터 흐름:** 마크다운 파일 수정 -> Vite 플러그인 감지 -> `generate-posts.mjs` 실행 -> `data/posts.json` 업데이트 -> UI 반영 | ||
|
|
||
| ## 배포 | ||
| - GitHub Actions를 통해 `main` 브랜치 푸시 시 자동 배포됩니다. | ||
| - 배포 설정은 `.github/workflows/deploy.yml`에 정의되어 있습니다. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
src,alt, andsafeAttrsvariables are inserted directly into the HTML string without any escaping or sanitization. This can lead to Cross-Site Scripting (XSS) if these values contain malicious content (e.g., a double quote followed by an event handler like" onerror="alert(1)"). Since these values originate from markdown files, they should be properly escaped before being rendered into HTML.