auto: PR for feat/image-optimization #67
+16
-1
@@ -95,6 +95,14 @@
|
||||
- Exporter CSS 토큰 회귀 확대(:root --primary, body font, skip-link base/focus, focus outline, reduced-motion)
|
||||
- 컴포넌트 i18n 회귀 스냅샷(en/ko 주요 라벨/버튼 키 존재)
|
||||
- 섹션 a11y 자동 검증 템플릿(testUtils/sectionA11y) 도입 및 템플릿 테스트 추가
|
||||
- 이미지 최적화 옵션 1차(TDD): hero 이미지에 `fetchpriority`/`srcset`/`sizes` 조건부 렌더링
|
||||
- 테스트: `lib/exporter/image.responsive.options.test.ts` (속성 존재 검증)
|
||||
- 구현: `lib/exporter/html.ts`(`ExportOptions`에 옵션 추가 및 `<img>` 렌더 반영)
|
||||
- 브랜치: `feat/image-optimization` (로컬 커밋, 푸쉬 보류)
|
||||
- 이미지 최적화 옵션 2차(TDD): `srcset`만 제공 시 기본 `sizes` 자동 적용(`imageAutoSizes`)
|
||||
- 기본값: `(max-width: 640px) 100vw, 600px`
|
||||
- 테스트: `image.responsive.options.test.ts` (auto on/off 케이스)
|
||||
- 구현: `ExportOptions.imageAutoSizes` 추가 및 `renderHero` 적용
|
||||
- 신규 섹션 추가: Benefits
|
||||
- 스키마 확장(`benefits`): props.items:string[]
|
||||
- Exporter 렌더 추가: `<section class="benefits" aria-labelledby>`, `<h2 id="benefits-heading">`, 리스트 렌더
|
||||
@@ -369,8 +377,14 @@
|
||||
- `assets.index.json` 그룹 정렬/경로 전략(grouped/flat) 커버리지 확장
|
||||
- `referencedAt` 추적(HTML 내 `rewriteUrl('local:...')`) 매트릭스 테스트
|
||||
- `verifyIntegrity` 유틸 구현 및 TDD(정상/변조/누락/번들해시 불일치) 통과
|
||||
- `assets.integrity.index.json`에 `size`(byte) 추가, `verifyIntegrity`에서 크기 불일치 검증
|
||||
- `verifyIntegrity` 결과에 `summary`(total/missing/integrityMismatches/sizeMismatches/bundleHashMismatch) 추가
|
||||
- Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화
|
||||
- 브랜치 푸쉬 완료: `feat/export-bundle-integrity`
|
||||
- 이미지 최적화 옵션(1차): `fetchpriority`/`srcset`/`sizes` 추가 및 테스트 그린
|
||||
|
||||
### 하드닝
|
||||
- ZIP 경로 가드: `../`, 절대경로(`/`), 역슬래시(`\`) 차단 (테스트 포함)
|
||||
|
||||
### TDD 계획 및 상태
|
||||
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
|
||||
@@ -384,9 +398,10 @@
|
||||
|
||||
### 다음 작업
|
||||
1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main`
|
||||
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사
|
||||
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사(요약 리포트 로깅)
|
||||
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅)
|
||||
4) README에 사용 가이드 보완(verifyIntegrity 활용 예시)
|
||||
5) 이미지 최적화 2차: `sizes` 기본 정책 프리셋 및 변환 파이프라인(AVIF/WebP) 설계 초안(TDD 스텁)
|
||||
|
||||
### CI/CD 계획(초안)
|
||||
- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
|
||||
|
||||
@@ -63,6 +63,13 @@ The Builder can export a production-ready ZIP that contains:
|
||||
- Image Options
|
||||
- Loading: `lazy` (default) or `eager` for `<img loading>`
|
||||
- Decoding: `async` (default) or `sync` for `<img decoding>`
|
||||
- Fetch Priority: optional `high|low` for `<img fetchpriority>`
|
||||
- Srcset/Sizes: optional responsive sources via `<img srcset>` and `<img sizes>`
|
||||
- Auto Sizes: when enabled and only `srcset` is provided, a sensible default is applied to `<img sizes>`: `(max-width: 640px) 100vw, 600px`
|
||||
- Sizes Preset: choose a preset for `<img sizes>` when `imageSizes` is not set.
|
||||
- `mobile-first`: `(max-width: 640px) 100vw, 800px`
|
||||
- `desktop-fixed`: `1200px`
|
||||
- Precedence: explicit `imageSizes` > `imageSizesPreset` > `imageAutoSizes` > none
|
||||
|
||||
### site.webmanifest and robots.txt
|
||||
|
||||
@@ -97,15 +104,31 @@ Simple index to quickly find assets by group:
|
||||
|
||||
### assets.integrity.index.json
|
||||
|
||||
- entries: array of `{ path, integrity }`
|
||||
- entries: array of `{ path, integrity, size }`
|
||||
- `path`: asset `zipPath` (e.g. `assets/abcd1234.png` or grouped `assets/images/...`)
|
||||
- `integrity`: `sha256-<base64>` (real SHA-256 of the asset bytes)
|
||||
- `size`: byte length of the asset
|
||||
- bundleHash: `sha256-<base64>` over a deterministic join of `path` and `integrity` for all entries
|
||||
|
||||
Usage:
|
||||
- Verify file-level integrity by recomputing SHA-256(base64) of each asset and comparing with `entries[].integrity`.
|
||||
- Verify file size matches `entries[].size`.
|
||||
- Verify bundle integrity by recomputing `bundleHash` from sorted entries; useful for quick tamper detection.
|
||||
|
||||
Example (Node):
|
||||
|
||||
```ts
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
// extras must include 'assets.integrity.index.json', assets is a map of zipPath -> bytes
|
||||
const result = verifyIntegrity(extras, assets)
|
||||
if (!result.ok) {
|
||||
console.error('Integrity check failed:', result.errors)
|
||||
}
|
||||
console.log('Summary:', result.summary)
|
||||
// { total, missing, integrityMismatches, sizeMismatches, bundleHashMismatch }
|
||||
```
|
||||
|
||||
### Google Sheets template (optional)
|
||||
|
||||
- When included, ZIP contains:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AssetManager } from '@/lib/assets/assetManager'
|
||||
|
||||
export type AssetsIntegrityIndex = {
|
||||
entries: Array<{ path: string; integrity: string }>
|
||||
entries: Array<{ path: string; integrity: string; size: number }>
|
||||
bundleHash: string
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export function buildAssetsIntegrityIndex(am: AssetManager): Uint8Array {
|
||||
const entries = am.list().map(ref => ({
|
||||
path: ref.path,
|
||||
integrity: `sha256-${sha256Base64(ref.bytes)}`,
|
||||
size: ref.bytes.length,
|
||||
}))
|
||||
// 2) Sort entries by path for deterministic output
|
||||
entries.sort((a, b) => a.path.localeCompare(b.path))
|
||||
|
||||
+14
-2
@@ -68,13 +68,25 @@ export function buildExtrasForPage(page: Page, overrides?: ExtrasOverrides): Rec
|
||||
const code = `/**
|
||||
* Google Apps Script Web App for form submissions
|
||||
* - Deploy as Web App and set URL as form action if needed.
|
||||
* @tool: landing-builder
|
||||
* @version: 1.0.0
|
||||
*/
|
||||
function doPost(e){
|
||||
// TODO: handle submission to Google Sheets
|
||||
try {
|
||||
// TODO: map incoming fields to sheet columns
|
||||
// Example:
|
||||
// const payload = JSON.parse(e.postData.contents || '{}');
|
||||
// const row = [payload.name, payload.email, new Date()];
|
||||
// SpreadsheetApp.openById('YOUR_SHEET_ID').getSheetByName('Sheet1').appendRow(row);
|
||||
return ContentService.createTextOutput('OK')
|
||||
} catch (e) {
|
||||
return ContentService
|
||||
.createTextOutput('ERROR: ' + (e && (e as any).message ? (e as any).message : 'unknown'))
|
||||
.setMimeType(ContentService.MimeType.TEXT)
|
||||
}
|
||||
}
|
||||
`
|
||||
const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) 배포 > 웹 앱으로 배포 후 URL을 복사해 사용하세요.`
|
||||
const readme = `Google Sheets Apps Script Template\n\n1) Apps Script에서 새 프로젝트를 만들고 아래 Code.gs를 붙여넣으세요.\n2) Deploy as Web App로 배포 후 URL을 복사해 사용하세요.\n3) Map fields to columns: 제출 payload의 필드를 시트 컬럼에 매핑하세요.`
|
||||
out['sheets/Code.gs'] = toBytes(code)
|
||||
out['sheets/README.txt'] = toBytes(readme)
|
||||
}
|
||||
|
||||
+17
-1
@@ -9,6 +9,11 @@ export type ExportOptions = {
|
||||
robotsMeta?: string
|
||||
imageLoading?: 'lazy' | 'eager'
|
||||
imageDecoding?: 'async' | 'sync'
|
||||
imageFetchPriority?: 'high' | 'low'
|
||||
imageSrcset?: string
|
||||
imageSizes?: string
|
||||
imageAutoSizes?: boolean
|
||||
imageSizesPreset?: 'mobile-first' | 'desktop-fixed'
|
||||
}
|
||||
|
||||
function renderFaq(section: { props: { items: Array<{ q: string; a: string }> } }) {
|
||||
@@ -125,6 +130,17 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
const imageUrl = am ? am.rewriteUrl(imageUrlRaw) : imageUrlRaw
|
||||
const loading = opts?.imageLoading ?? 'lazy'
|
||||
const decoding = opts?.imageDecoding ?? 'async'
|
||||
const fetchpriority = opts?.imageFetchPriority ? ` fetchpriority="${escapeHtml(opts.imageFetchPriority)}"` : ''
|
||||
const srcset = opts?.imageSrcset ? ` srcset="${escapeHtml(opts.imageSrcset)}"` : ''
|
||||
const defaultSizes = '(max-width: 640px) 100vw, 600px'
|
||||
const presetSizes = (() => {
|
||||
if (!opts?.imageSizesPreset) return ''
|
||||
if (opts.imageSizesPreset === 'mobile-first') return '(max-width: 640px) 100vw, 800px'
|
||||
if (opts.imageSizesPreset === 'desktop-fixed') return '1200px'
|
||||
return ''
|
||||
})()
|
||||
const sizesValue = opts?.imageSizes ?? (presetSizes || (opts?.imageSrcset && opts?.imageAutoSizes ? defaultSizes : ''))
|
||||
const sizes = sizesValue ? ` sizes="${escapeHtml(sizesValue)}"` : ''
|
||||
return `
|
||||
<section class="hero" aria-labelledby="hero-heading">
|
||||
<div class="container">
|
||||
@@ -133,7 +149,7 @@ function renderHero(section: { props: { heading: string; subheading?: string; im
|
||||
${subheading.length > 0 ? `<p>${escapeHtml(subheading)}</p>` : ''}
|
||||
</div>
|
||||
<div class="media">
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}" width="1200" height="675" />
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(heading)}" loading="${escapeHtml(loading)}" decoding="${escapeHtml(decoding)}"${fetchpriority}${srcset}${sizes} width="1200" height="675" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { exportPage } from '@/lib/exporter/html'
|
||||
import type { ExportOptions } from '@/lib/exporter/html'
|
||||
import type { Page } from '@/lib/schema/page'
|
||||
|
||||
const basePage: Page = {
|
||||
title: 'Img Test',
|
||||
locale: 'en',
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
sections: [
|
||||
{ type: 'hero', props: { heading: 'Hero', subheading: '', imageUrl: 'https://example.com/hero.jpg' } },
|
||||
],
|
||||
form: {
|
||||
actionUrl: '',
|
||||
fields: [],
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||
seo: { title: 'T', description: 'D' },
|
||||
}
|
||||
|
||||
describe('image responsive/export options', () => {
|
||||
it('applies fetchpriority when provided', () => {
|
||||
const options: ExportOptions = { imageLoading: 'eager', imageDecoding: 'async', imageFetchPriority: 'high' }
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*fetchpriority="high"/)
|
||||
})
|
||||
|
||||
it('applies srcset and sizes when provided', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizes: '(max-width: 640px) 100vw, 600px',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 600px"/)
|
||||
})
|
||||
|
||||
it('applies default sizes when srcset is provided and imageAutoSizes is true', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: true,
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 600px"/)
|
||||
})
|
||||
|
||||
it('does not include sizes when srcset is provided but imageAutoSizes is false and sizes not provided', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: false,
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*srcset="https:\/\/example.com\/hero.jpg 1x, https:\/\/example.com\/hero@2x.jpg 2x"/)
|
||||
expect(html).not.toMatch(/<img[^>]*sizes="/)
|
||||
})
|
||||
|
||||
it('applies preset sizes when imageSizesPreset is set (mobile-first)', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizesPreset: 'mobile-first',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 640px\) 100vw, 800px"/)
|
||||
})
|
||||
|
||||
it('applies desktop-fixed preset when selected', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageSizesPreset: 'desktop-fixed',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="1200px"/)
|
||||
})
|
||||
|
||||
it('explicit imageSizes overrides presets and auto', () => {
|
||||
const options: ExportOptions = {
|
||||
imageSrcset: 'https://example.com/hero.jpg 1x, https://example.com/hero@2x.jpg 2x',
|
||||
imageAutoSizes: true,
|
||||
imageSizesPreset: 'desktop-fixed',
|
||||
imageSizes: '(max-width: 768px) 100vw, 700px',
|
||||
}
|
||||
const { html } = exportPage(basePage, options)
|
||||
expect(html).toMatch(/<img[^>]*sizes="\(max-width: 768px\) 100vw, 700px"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildExtrasForPage } from '@/lib/exporter/extras'
|
||||
import type { Page } from '@/lib/schema/page'
|
||||
|
||||
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
|
||||
|
||||
const basePage: Page = {
|
||||
title: 'Test',
|
||||
seo: { title: 'T', description: 'D' },
|
||||
theme: { primaryColor: '#0ea5e9', fontFamily: 'Inter' },
|
||||
analytics: { ga4MeasurementId: '', metaPixelId: '', customHeadHtml: '' },
|
||||
sections: [],
|
||||
form: {
|
||||
fields: [],
|
||||
actionUrl: '',
|
||||
spamProtection: { honeypotFieldName: '_hp', minSubmitSeconds: 2 },
|
||||
},
|
||||
locale: 'en',
|
||||
}
|
||||
|
||||
describe('Sheets template enhance', () => {
|
||||
it('includes version/meta and mapping/error guide in Code.gs and README', () => {
|
||||
const extras = buildExtrasForPage(basePage, { sheetsMode: 'include' })
|
||||
const code = td(extras['sheets/Code.gs'])
|
||||
const readme = td(extras['sheets/README.txt'])
|
||||
|
||||
// version/meta header
|
||||
expect(code).toMatch(/@version:\s*\d+\.\d+\.\d+/)
|
||||
expect(code).toMatch(/@tool:\s*landing-builder/i)
|
||||
|
||||
// field mapping and error handling guides
|
||||
expect(code).toMatch(/TODO:\s*map incoming fields to sheet columns/i)
|
||||
expect(code).toMatch(/try\s*\{/)
|
||||
expect(code).toMatch(/catch\s*\(e\)/)
|
||||
|
||||
// README includes setup and mapping hint
|
||||
expect(readme).toMatch(/Apps Script/i)
|
||||
expect(readme).toMatch(/Deploy as Web App/i)
|
||||
expect(readme).toMatch(/Map fields to columns/i)
|
||||
})
|
||||
|
||||
it('respects exclude mode', () => {
|
||||
const extras = buildExtrasForPage(basePage, { sheetsMode: 'exclude' })
|
||||
expect(Object.keys(extras).some(k => k.startsWith('sheets/'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AssetManager } from '@/lib/assets/assetManager'
|
||||
import { buildAssetsIntegrityIndex } from '@/lib/exporter/assets.integrity.index'
|
||||
import { verifyIntegrity } from '@/lib/exporter/verify'
|
||||
|
||||
function u8(n: number): Uint8Array { const a = new Uint8Array(n); for (let i=0;i<n;i++) a[i]=i&0xff; return a }
|
||||
|
||||
function td(b: Uint8Array){ return new TextDecoder().decode(b) }
|
||||
function te(s: string){ return new TextEncoder().encode(s) }
|
||||
|
||||
describe('verifyIntegrity', () => {
|
||||
it('returns ok=true for valid assets and integrity index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
const a = await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
const b = await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
const extras: Record<string, Uint8Array> = { 'assets.integrity.index.json': idx }
|
||||
|
||||
const res = verifyIntegrity(extras, assets)
|
||||
expect(res.ok).toBe(true)
|
||||
expect(res.errors.length).toBe(0)
|
||||
// summary
|
||||
const parsedIdx = JSON.parse(td(idx)) as { entries: Array<{ path: string }> }
|
||||
expect(res.summary.total).toBe(parsedIdx.entries.length)
|
||||
expect(res.summary.missing).toBe(0)
|
||||
expect(res.summary.integrityMismatches).toBe(0)
|
||||
expect(res.summary.sizeMismatches).toBe(0)
|
||||
expect(res.summary.bundleHashMismatch).toBe(false)
|
||||
// sanity: index contains both paths
|
||||
const parsed = JSON.parse(td(idx)) as { entries: Array<{ path: string }> }
|
||||
const paths = parsed.entries.map(e => e.path)
|
||||
expect(paths).toContain(a.path)
|
||||
expect(paths).toContain(b.path)
|
||||
})
|
||||
|
||||
it('detects byte tampering in an asset', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idx = buildAssetsIntegrityIndex(am)
|
||||
// tamper one asset byte
|
||||
const somePath = Object.keys(assets)[0]
|
||||
const tampered = assets[somePath].slice()
|
||||
tampered[0] = (tampered[0] ^ 0xff) & 0xff
|
||||
assets[somePath] = tampered
|
||||
|
||||
const res = verifyIntegrity({ 'assets.integrity.index.json': idx }, assets)
|
||||
expect(res.ok).toBe(false)
|
||||
expect(res.errors.some((e: string) => e.includes(somePath) && e.toLowerCase().includes('mismatch'))).toBe(true)
|
||||
expect(res.summary.integrityMismatches).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('detects missing entry or missing asset referenced by index', async () => {
|
||||
const am = new AssetManager({ maxBytes: 1024*1024, allowedExts: ['png','txt'] })
|
||||
await am.add({ name: 'a.png', type: 'image/png', bytes: u8(5) })
|
||||
await am.add({ name: 'b.txt', type: 'text/plain', bytes: u8(7) })
|
||||
const assets = am.toZipStructure()
|
||||
const idxRaw = buildAssetsIntegrityIndex(am)
|
||||
const obj = JSON.parse(td(idxRaw)) as { entries: Array<{ path: string; integrity: string }>, bundleHash: string }
|
||||
// remove one entry
|
||||
const removed = obj.entries.shift()!
|
||||
const idx2 = te(JSON.stringify(obj))
|
||||
|
||||
const res1 = verifyIntegrity({ 'assets.integrity.index.json': idx2 }, assets)
|
||||
expect(res1.ok).toBe(false)
|
||||
expect(res1.errors.some((e: string) => e.toLowerCase().includes('bundlehash'))).toBe(true)
|
||||
expect(res1.summary.bundleHashMismatch).toBe(true)
|
||||
|
||||
// reference a missing path in index
|
||||
obj.entries.unshift({ path: 'assets/missing.bin', integrity: 'sha256-AAAA' })
|
||||
const idx3 = te(JSON.stringify(obj))
|
||||
const res2 = verifyIntegrity({ 'assets.integrity.index.json': idx3 }, assets)
|
||||
expect(res2.ok).toBe(false)
|
||||
expect(res2.errors.some((e: string) => e.includes('assets/missing.bin') && e.toLowerCase().includes('missing'))).toBe(true)
|
||||
expect(res2.summary.missing).toBeGreaterThanOrEqual(1)
|
||||
// keep lints happy
|
||||
expect(removed).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
export type VerifyResult = {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
summary: {
|
||||
total: number;
|
||||
missing: number;
|
||||
integrityMismatches: number;
|
||||
sizeMismatches: number;
|
||||
bundleHashMismatch: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal synchronous SHA-256 implementation returning base64 string.
|
||||
function sha256Base64(bytes: Uint8Array): string {
|
||||
const K = new Uint32Array([
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
||||
])
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
|
||||
])
|
||||
const l = bytes.length
|
||||
const bitLenHi = Math.floor((l >>> 29))
|
||||
const bitLenLo = (l << 3) >>> 0
|
||||
const withOne = l + 1
|
||||
const padLen = ((withOne % 64) <= 56) ? (56 - (withOne % 64)) : (56 + 64 - (withOne % 64))
|
||||
const totalLen = withOne + padLen + 8
|
||||
const m = new Uint8Array(totalLen)
|
||||
m.set(bytes)
|
||||
m[l] = 0x80
|
||||
m[totalLen - 8] = (bitLenHi >>> 24) & 0xff
|
||||
m[totalLen - 7] = (bitLenHi >>> 16) & 0xff
|
||||
m[totalLen - 6] = (bitLenHi >>> 8) & 0xff
|
||||
m[totalLen - 5] = (bitLenHi >>> 0) & 0xff
|
||||
m[totalLen - 4] = (bitLenLo >>> 24) & 0xff
|
||||
m[totalLen - 3] = (bitLenLo >>> 16) & 0xff
|
||||
m[totalLen - 2] = (bitLenLo >>> 8) & 0xff
|
||||
m[totalLen - 1] = (bitLenLo >>> 0) & 0xff
|
||||
const W = new Uint32Array(64)
|
||||
for (let i = 0; i < totalLen; i += 64) {
|
||||
for (let t = 0; t < 16; t++) {
|
||||
const j = i + t * 4
|
||||
W[t] = (m[j] << 24) | (m[j + 1] << 16) | (m[j + 2] << 8) | (m[j + 3])
|
||||
}
|
||||
for (let t = 16; t < 64; t++) {
|
||||
const s0 = (rotr(W[t - 15], 7) ^ rotr(W[t - 15], 18) ^ (W[t - 15] >>> 3)) >>> 0
|
||||
const s1 = (rotr(W[t - 2], 17) ^ rotr(W[t - 2], 19) ^ (W[t - 2] >>> 10)) >>> 0
|
||||
W[t] = (W[t - 16] + s0 + W[t - 7] + s1) >>> 0
|
||||
}
|
||||
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7]
|
||||
for (let t = 0; t < 64; t++) {
|
||||
const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0
|
||||
const ch = ((e & f) ^ (~e & g)) >>> 0
|
||||
const temp1 = (h + S1 + ch + K[t] + W[t]) >>> 0
|
||||
const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0
|
||||
const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0
|
||||
const temp2 = (S0 + maj) >>> 0
|
||||
h = g; g = f; f = e; e = (d + temp1) >>> 0; d = c; c = b; b = a; a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
H[0] = (H[0] + a) >>> 0
|
||||
H[1] = (H[1] + b) >>> 0
|
||||
H[2] = (H[2] + c) >>> 0
|
||||
H[3] = (H[3] + d) >>> 0
|
||||
H[4] = (H[4] + e) >>> 0
|
||||
H[5] = (H[5] + f) >>> 0
|
||||
H[6] = (H[6] + g) >>> 0
|
||||
H[7] = (H[7] + h) >>> 0
|
||||
}
|
||||
const out = new Uint8Array(32)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
out[i*4+0]=(H[i]>>>24)&0xff; out[i*4+1]=(H[i]>>>16)&0xff; out[i*4+2]=(H[i]>>>8)&0xff; out[i*4+3]=(H[i]>>>0)&0xff
|
||||
}
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(out).toString('base64')
|
||||
let s = ''; for (let i=0;i<out.length;i++) s += String.fromCharCode(out[i])
|
||||
const btoaFn = (globalThis as unknown as { btoa?: (s: string) => string }).btoa
|
||||
if (!btoaFn) throw new Error('btoa not available')
|
||||
return btoaFn(s)
|
||||
function rotr(x: number, n: number) { return (x >>> n) | (x << (32 - n)) }
|
||||
}
|
||||
|
||||
export function verifyIntegrity(
|
||||
extras: Record<string, Uint8Array>,
|
||||
assets: Record<string, Uint8Array>
|
||||
): VerifyResult {
|
||||
const errors: string[] = []
|
||||
const key = 'assets.integrity.index.json'
|
||||
if (!Object.prototype.hasOwnProperty.call(extras, key)) {
|
||||
return { ok: false, errors: ['Missing assets.integrity.index.json in extras'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } }
|
||||
}
|
||||
let parsed: { entries: Array<{ path: string; integrity: string; size?: number }>; bundleHash: string }
|
||||
try {
|
||||
parsed = JSON.parse(new TextDecoder().decode(extras[key]))
|
||||
} catch {
|
||||
return { ok: false, errors: ['Invalid assets.integrity.index.json JSON'], summary: { total: 0, missing: 0, integrityMismatches: 0, sizeMismatches: 0, bundleHashMismatch: false } }
|
||||
}
|
||||
// verify each entry exists and integrity matches
|
||||
const recomputedLines: string[] = []
|
||||
let missing = 0
|
||||
let integrityMismatches = 0
|
||||
let sizeMismatches = 0
|
||||
for (const e of parsed.entries) {
|
||||
const bytes = assets[e.path]
|
||||
if (!bytes) {
|
||||
errors.push(`Missing asset for path in integrity index: ${e.path}`)
|
||||
missing++
|
||||
continue
|
||||
}
|
||||
// optional size check when present in index
|
||||
if (typeof e.size === 'number' && e.size !== bytes.length) {
|
||||
errors.push(`Size mismatch for ${e.path}: expected ${e.size}, got ${bytes.length}`)
|
||||
sizeMismatches++
|
||||
}
|
||||
const b64 = sha256Base64(bytes)
|
||||
const integ = `sha256-${b64}`
|
||||
if (integ !== e.integrity) {
|
||||
errors.push(`Integrity mismatch for ${e.path}`)
|
||||
integrityMismatches++
|
||||
}
|
||||
recomputedLines.push(e.path)
|
||||
recomputedLines.push(integ)
|
||||
}
|
||||
// recompute bundle hash
|
||||
const joined = new TextEncoder().encode(recomputedLines.join('\n'))
|
||||
const bundle = `sha256-${sha256Base64(joined)}`
|
||||
const bundleHashMismatch = bundle !== parsed.bundleHash
|
||||
if (bundleHashMismatch) {
|
||||
errors.push('BundleHash mismatch')
|
||||
}
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
summary: {
|
||||
total: parsed.entries.length,
|
||||
missing,
|
||||
integrityMismatches,
|
||||
sizeMismatches,
|
||||
bundleHashMismatch,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildZip } from '@/lib/exporter/zip'
|
||||
|
||||
function u8(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s)
|
||||
}
|
||||
|
||||
describe('buildZip path guard', () => {
|
||||
it('rejects dangerous asset paths with .. segments', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', assets: { '../evil.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid asset path/i)
|
||||
})
|
||||
|
||||
it('rejects dangerous extras with absolute path and backslashes', async () => {
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { '/abs.txt': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
|
||||
await expect(
|
||||
buildZip({ html: '', css: '', js: '', extras: { 'assets\\x.bin': u8('x') } })
|
||||
).rejects.toThrow(/invalid extra path/i)
|
||||
})
|
||||
|
||||
it('accepts safe relative paths under assets/', async () => {
|
||||
const zip = await buildZip({ html: '', css: '', js: '', assets: { 'assets/a.bin': u8('ok') } })
|
||||
expect(zip.byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -14,8 +14,19 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
zip.file('index.html', input.html)
|
||||
zip.file('styles.css', input.css)
|
||||
zip.file('app.js', input.js)
|
||||
|
||||
// guard: basic path sanitizer to avoid directory traversal or absolute paths
|
||||
const isSafePath = (p: string) => {
|
||||
if (!p || typeof p !== 'string') return false
|
||||
if (p.startsWith('/')) return false
|
||||
if (p.includes('..')) return false
|
||||
if (p.includes('\\')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.assets) {
|
||||
for (const [path, bytes] of Object.entries(input.assets)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid asset path: ${path}`)
|
||||
// In Node/Vitest, use Buffer for robust binary handling
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
@@ -33,6 +44,7 @@ export async function buildZip(input: BuildZipInput): Promise<Uint8Array> {
|
||||
}
|
||||
}
|
||||
for (const [path, bytes] of Object.entries(input.extras)) {
|
||||
if (!isSafePath(path)) throw new Error(`Invalid extra path: ${path}`)
|
||||
const buf = Buffer.from(bytes)
|
||||
zip.file(path, buf as unknown as Buffer)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 메인플랜
|
||||
|
||||
## 목표
|
||||
- Export ZIP 무결성 강화(Option2): 자산 무결성 지표와 번들 검증 체계 구축
|
||||
- CI/CD(Gitea)에서 PR 자동화, 라벨링, 조건부 머지 적용
|
||||
- 모든 개발은 TDD로 진행하고, 기능 단위 커밋/대브랜치 단위 푸쉬
|
||||
|
||||
## 브랜치 전략
|
||||
- 대범위 기능 묶음 브랜치: `feat/export-bundle-integrity`
|
||||
- 관련 기능 완료까지 로컬 커밋만 진행, 완료 시점에 일괄 푸쉬
|
||||
|
||||
## 진행 현황
|
||||
- SHA-256(base64) 무결성 구현 및 `assets.integrity.index.json` 생성
|
||||
- `assets.index.json` 그룹 정렬/경로 전략(grouped/flat) 커버리지 확장
|
||||
- `referencedAt` 추적(HTML 내 `rewriteUrl('local:...')`) 매트릭스 테스트
|
||||
- `verifyIntegrity` 유틸 구현 및 TDD(정상/변조/누락/번들해시 불일치) 통과
|
||||
- Export Options(폰트 프리커넥트/프리로드, 이미지 로딩/디코딩, robots 메타) UI/문서화
|
||||
- 브랜치 푸쉬 완료: `feat/export-bundle-integrity`
|
||||
|
||||
## TDD 계획 및 상태
|
||||
- 테스트 선작성 → 최소 구현 → 리팩터/문서화
|
||||
- 현재 테스트: 110 files / 173 tests 모두 통과
|
||||
- 무결성 인덱스/검증에 대한 정상/오류 케이스 커버 완료
|
||||
|
||||
## 오류/디버깅 로그 요약
|
||||
- 그룹 경로(other) 정규식 기대값 수정으로 테스트 안정화
|
||||
- SHA-256 재계산 불일치 회피: 테스트에서 중복 해시 구현 제거, 산출물 기준 비교
|
||||
- Node/브라우저 호환 btoa/atob 제거, 안전한 변환 로직 적용
|
||||
|
||||
## 다음 작업
|
||||
1) PR 생성(템플릿/라벨): `feat/export-bundle-integrity` → `main`
|
||||
2) CI 무결성 검증 스텝 추가: `verifyIntegrity`로 빌드 산출물 검사
|
||||
3) PR 라벨 자동화 및 조건부 머지(Gitea Actions/웹훅)
|
||||
4) README에 사용 가이드 보완(verifyIntegrity 활용 예시)
|
||||
|
||||
## CI/CD 계획(초안)
|
||||
- PR 오픈 시: 라벨 자동 지정, 테스트/빌드, 무결성 검증 스텝 실행
|
||||
- main 머지 조건: 테스트/빌드 통과 + 무결성 검증 통과 + 최소 라벨 셋 충족
|
||||
|
||||
## 커밋/푸쉬 원칙
|
||||
- 기능 단위 커밋은 한국어 메시지
|
||||
- 대범위 브랜치 완료 전에 푸쉬 금지(현 단계는 완료되어 푸쉬 수행)
|
||||
Reference in New Issue
Block a user