[fix] solution: 엽서·가요 다방 손질 — 재굽기 전 사이트도 공유되게 사진을 중계한다

배포 뒤 대표 지적 넷을 한 번에 고친다.

- 사진 중계(`backend/router/v1/media/relay.py`, `/v1/image/relay`) — 굽는 쪽이 사진을
  우리 자리로 옮기는 건 **다시 굽는 사이트에만** 적용된다. 아직 재발행 안 한 사이트는
  캔버스가 오염된 채라 저장·공유가 막혀 있다. 같은 오리진으로 바이트를 흘려보내 그 사이를
  메운다. `postcard-canvas.loadImage` 가 CORS 실패 때만 두 번째로 탄다 —
  이미 우리 자리에 있는 사진은 첫 시도에서 끝난다.
  ★ 열린 프록시가 되지 않게: https · 호스트 allowlist · 이미지 타입 · 8MB ·
    리다이렉트 후 호스트 재검사 · IP 주소 거절. 실측: pstatic 사진 158KB 통과,
    http·목록 밖·127.0.0.1·169.254.169.254 전부 거절
- 엽서 미리보기 360px (320 → 240 은 작다 하셔서 그 사이로)
- 엽서 사진 목록에 슬라이더가 없던 것 — `.slider-viewport` 마크업만 손으로 찍어서
  embla 가 안 붙어 있었다(그건 스크립트 붙기 전의 기본 상태다). 진짜 `Carousel` 로 바꾼다
- 가요 다방 판이 전부 주朱색이던 것 — **실측 67곡 중 63곡에 `labelColor` 가 없다.**
  모델이 공통 규칙 2("확인 안 된 값은 뺀다")를 색에까지 적용한다. 색은 사실이 아니라
  디자인 값이다: 프롬프트에 그렇게 못 박고, 비었을 때는 제목 해시로 레코드 라벨색 8종에서
  고른다(무작위가 아니다 — 하이드레이션이 어긋나면 안 되고, 새로고침마다 바뀌어도 안 된다)

tsc·eslint 통과(site), site 79 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-15 09:51:53 +09:00
parent 6b9e01d876
commit f0c4d5f413
8 changed files with 174 additions and 24 deletions

View File

@ -50,7 +50,11 @@
→ 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다.
→ 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다.
→ ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다
(아래 항목).
(아래 항목). 그 사이를 메우는 건 **중계**다 — `/v1/image/relay?url=…`
(`backend/router/v1/media/relay.py`). 캔버스가 CORS 로 사진을 못 받으면 같은 오리진의
이 주소로 한 번 더 받아 본다(`site/src/lib/postcard-canvas.ts` `loadImage`).
열린 프록시가 아니다 — https · 호스트 allowlist · 이미지 타입 · 8MB · 리다이렉트 후
호스트 재검사. **호스트를 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는가" 를 먼저 본다.**
`originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**

View File

@ -15,6 +15,7 @@ import router.v1.place.place
import router.v1.fact.fact
import router.v1.faq.faq
import router.v1.media.media
import router.v1.media.relay
import router.v1.job.job
import router.v1.site.site
import router.v1.site.showcase
@ -86,6 +87,9 @@ app.include_router(router.v1.place.place.router)
app.include_router(router.v1.fact.fact.router)
app.include_router(router.v1.faq.faq.router)
app.include_router(router.v1.media.media.router)
# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 —
# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py).
app.include_router(router.v1.media.relay.router)
app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router)

View File

@ -0,0 +1,87 @@
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다.
필요한가 (2026-09-15, 실측)
수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 있고 그쪽은
`Access-Control-Allow-Origin` 주지 않는다. 사진을 캔버스에 그리면 캔버스가 **오염돼**
`toBlob` 막힌다 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는
넘는다. CORS 없는 `fetch` 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다.
굽는 (`site/scripts/prerender.ts` mirrorMedia) 이미 사진을 내려받아 사이트 폴더에
놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 이미 나가 있는
사이트는 사장님이 재발행할 때까지 주소를 문다. 중계가 사이를 메운다.
열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 아니다:
· https
· 호스트가 `_ALLOWED_SUFFIXES` 있는 것만 (우리 수집기가 쓰는 사진 CDN)
· 응답이 이미지가 아니면 거절, 크기 상한
· 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 그러면 allowlist 우회한다
"""
import ipaddress
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter, HTTPException, Query, Response
from common.logger import LOG
router = APIRouter(prefix="/v1/image", tags=["Image"])
# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다.
# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다.
_ALLOWED_SUFFIXES = (
".pstatic.net",
"tong.visitkorea.or.kr",
".visitkorea.or.kr",
"upload.wikimedia.org",
)
_MAX_BYTES = 8 * 1024 * 1024
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다.
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
def _allowed(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "https" or not parsed.hostname:
return False
host = parsed.hostname.lower()
# 숫자 주소는 받지 않는다 — 사내망으로 향하는 통로를 만들지 않는다.
try:
ipaddress.ip_address(host)
return False
except ValueError:
pass
return any(host == suffix.lstrip(".") or host.endswith(suffix) for suffix in _ALLOWED_SUFFIXES)
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
async def relay(url: str = Query(min_length=8, max_length=2000)):
"""인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는
것은 ** 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 그걸 보장한다)."""
if not _allowed(url):
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
try:
async with httpx.AsyncClient(timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True) as client:
res = await client.get(url)
except httpx.HTTPError as ex:
LOG.w(f"[image] 중계 실패 {url}: {ex}")
raise HTTPException(status_code=502, detail="사진을 가져오지 못했습니다")
# 리다이렉트로 allowlist 밖으로 나갔으면 거기서 끊는다.
if not _allowed(str(res.url)):
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
kind = (res.headers.get("content-type") or "").split(";")[0].strip().lower()
if res.status_code != 200 or not kind.startswith("image/"):
raise HTTPException(status_code=502, detail="사진이 아닙니다")
if len(res.content) == 0 or len(res.content) > _MAX_BYTES:
raise HTTPException(status_code=502, detail="사진 크기가 범위를 벗어났습니다")
# 주소가 곧 내용이다(원본이 바뀌면 주소도 바뀐다) — 길게 캐시해서 중계 횟수를 줄인다.
return Response(
content=res.content,
media_type=kind,
headers={"cache-control": "public, max-age=604800, immutable"},
)

View File

@ -7,7 +7,7 @@
"label": "가요 다방",
"maxItems": 8,
"task": "[해야 할 일]\n[지역]을 노래한 대중가요를 8곡까지 찾아 아래 JSON 으로 정리한다.\n1960~80년대 곡을 우선하고, 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.\n\n[스키마]\n{ \"kind\":\"songs\", \"version\":1, \"title\":\"가요 다방\", \"subtitle\":\"...\", \"items\":[\n { \"title\":\"곡명\", \"artist\":\"가수\", \"lyricist\":\"작사\", \"composer\":\"작곡\",\n \"year\":1966, \"label\":\"음반사\", \"labelColor\":\"#d4551f\",\n \"story\":\"곡의 배경 (두 문장 이내, 가사 없이)\",\n \"connection\":\"[업소]와 이 곡을 잇는 한 문장\",\n \"verified\":\"확인|확인필요\",\n \"source\":{\"name\":\"출처명\",\"url\":\"https://...\"} } ] }",
"rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n"
"rules": "\n[이 아이템만의 규칙]\n· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.\n· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.\n **이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.**\n 앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15:\n 67곡 중 63곡이 비어 있었다).\n· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. \"미상\" 이라고 쓰지 않는다.\n"
},
"daily": {
"kind": "daily",

View File

@ -92,6 +92,9 @@ export const SECTION_PROMPTS: Record<StoryKind, SectionPromptSpec> = {
[ ]
· lyrics . . .
· labelColor . hex .
** 2 .**
. ( 2026-09-15:
67 63 ).
· ·· . "미상" .
`,
},

View File

@ -112,22 +112,39 @@ function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: numbe
* **CORS ** (· ).
* **2026-09-15 ** `s/<slug>/img/`
* payload (`scripts/prerender.ts` `mirrorMedia`).
* · . ** **
* (CDN ) .
* . ** **
* .
* **** (`backend/router/v1/media/relay.py`). :
* `/v1/image/relay?url=…`
* . ( ) .
* .
* .
*/
const RELAY = '/v1/image/relay?url=';
async function loadImage(url: string): Promise<{img: HTMLImageElement; exportable: boolean}> {
const tryLoad = (crossOrigin: string | null) => new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
if (crossOrigin) img.crossOrigin = crossOrigin;
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const tryLoad = (src: string, crossOrigin: string | null) =>
new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
if (crossOrigin) img.crossOrigin = crossOrigin;
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
try {
return {img: await tryLoad('anonymous'), exportable: true};
return {img: await tryLoad(url, 'anonymous'), exportable: true};
} catch {
return {img: await tryLoad(null), exportable: false};
// 남의 CDN 이 CORS 를 안 줬다 — 우리 서버를 거쳐 같은 오리진으로 받아 본다.
}
if (/^https?:\/\//i.test(url)) {
try {
return {img: await tryLoad(`${RELAY}${encodeURIComponent(url)}`, 'anonymous'), exportable: true};
} catch {
// 중계가 없는 환경(정적 호스팅만 있는 미리보기)이거나 allowlist 밖이다.
}
}
return {img: await tryLoad(url, null), exportable: false};
}
/** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */

View File

@ -11,7 +11,7 @@
*/
import {useEffect, useRef, useState} from 'react';
import {useSite} from '@site/lib/site-context';
import {Section} from '@site/lib/ui';
import {Carousel, Section} from '@site/lib/ui';
import {MAX_LINES, POSTCARD_SIZE, drawPostcard, textColumnWidth, wrapLines} from '@site/lib/postcard-canvas';
const FILE_NAME = 'postcard.png';
@ -114,18 +114,23 @@ export function PostcardMakerSection() {
{/* (2026-09-15 : " ui "). 1:1
700px ,
. .
320px (2026-09-15, ) 240px . */}
<div className="mx-auto grid max-w-2xl gap-4 sm:grid-cols-[minmax(0,240px)_minmax(0,1fr)] sm:items-start">
(2026-09-15): 320 240( ) **360**.
"한 화면에서 엽서와 입력칸이 나란히 보이는 가장 큰 크기" . */}
<div className="mx-auto grid max-w-3xl gap-5 sm:grid-cols-[minmax(0,360px)_minmax(0,1fr)] sm:items-start">
<canvas
ref={canvasRef}
width={POSTCARD_SIZE}
height={POSTCARD_SIZE}
aria-label="내가 만든 엽서 미리보기"
className="border-line mx-auto w-full max-w-[240px] border"
className="border-line mx-auto w-full max-w-[360px] border"
/>
<div className="flex flex-col gap-2.5">
<div className="slider-viewport" role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`} tabIndex={0}>
<div className="slider-track" style={{gap: '0.5rem'}}>
{/* (2026-09-15 : "
slide js ?"). `.slider-viewport`·`.slider-track`
, ** **(Carousel.tsx )
· . */}
<div role="radiogroup" aria-label={`엽서 사진 고르기(${photos.length}장)`}>
<Carousel label="엽서 사진" gap={0.5} dots={false} autoplay={false}>
{photos.map((photo, i) => (
<button
key={photo.url}
@ -140,7 +145,7 @@ export function PostcardMakerSection() {
<img src={photo.url} alt="" loading="lazy" className="size-full object-cover" />
</button>
))}
</div>
</Carousel>
</div>
<textarea

View File

@ -24,8 +24,38 @@ import {
tally,
} from './common';
/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */
const FALLBACK_LABEL = ITEM_ACCENT;
/**
* .
*
* (2026-09-15 : "왜 색깔 다 빨간색이야")
* 릿 (`ITEM_ACCENT`). ** 67 63
* `labelColor` ** 2
* ("확인되지 않은 값은 필드를 통째로 뺀다") .
* .
* ** .**
* . .
* ** .**
* , .
*/
const LABEL_PALETTE = [
'#8b1e3f', // 버건디
'#1f4e79', // 감청
'#2f5d30', // 짙은 초록
'#a8541a', // 주황갈색
'#5a3a22', // 고동
'#0f5d7a', // 청록
'#6b2d5c', // 자주
'#7a6216', // 겨자
];
/** 제목 → 팔레트 index. 짧고 안정적이면 된다(암호학적 용도가 아니다). */
function labelColorOf(song: SongItem): string {
if (song.labelColor) return song.labelColor;
const key = song.title ?? '';
let hash = 0;
for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
return LABEL_PALETTE[hash % LABEL_PALETTE.length];
}
export function SongsSection() {
const payload = useSite();
@ -72,7 +102,7 @@ export function SongsSection() {
<div
className="w4-disc w4-spin absolute inset-2 rounded-full"
style={{
['--lbl' as string]: current.labelColor || FALLBACK_LABEL,
['--lbl' as string]: labelColorOf(current),
['--w4-vinyl' as string]: ITEM_INVERSE,
}}
>
@ -159,7 +189,7 @@ export function SongsSection() {
<span
className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105"
style={{
['--lbl' as string]: song.labelColor || FALLBACK_LABEL,
['--lbl' as string]: labelColorOf(song),
['--w4-vinyl' as string]: ITEM_INVERSE,
boxShadow:
index === playing ? `0 0 0 2px ${ITEM_ACCENT}` : undefined,