diff --git a/AGENTS.md b/AGENTS.md index 74b091e..65342db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 을 빼는 그 대응이 그대로 먹어야 한다. - **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.** diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index 6e95b80..6fdb109 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -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) diff --git a/solution/backend/router/v1/media/relay.py b/solution/backend/router/v1/media/relay.py new file mode 100644 index 0000000..38697f3 --- /dev/null +++ b/solution/backend/router/v1/media/relay.py @@ -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"}, + ) diff --git a/solution/backend/services/prompts/section_prompts.json b/solution/backend/services/prompts/section_prompts.json index 2b2460a..8f43bef 100644 --- a/solution/backend/services/prompts/section_prompts.json +++ b/solution/backend/services/prompts/section_prompts.json @@ -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", diff --git a/solution/shared/src/lib/section-prompts.ts b/solution/shared/src/lib/section-prompts.ts index 94c26f3..0167646 100644 --- a/solution/shared/src/lib/section-prompts.ts +++ b/solution/shared/src/lib/section-prompts.ts @@ -92,6 +92,9 @@ export const SECTION_PROMPTS: Record = { [이 아이템만의 규칙] · lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다. · labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다. + **이건 사실이 아니라 디자인 값이다 — 공통 규칙 2 를 적용하지 말고 곡마다 반드시 채운다.** + 앞뒤 곡과 같은 색을 쓰지 않는다. 비워 두면 판이 전부 한 색으로 깔린다(실측 2026-09-15: + 67곡 중 63곡이 비어 있었다). · 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다. `, }, diff --git a/solution/site/src/lib/postcard-canvas.ts b/solution/site/src/lib/postcard-canvas.ts index a2a3ccc..6a072c3 100644 --- a/solution/site/src/lib/postcard-canvas.ts +++ b/solution/site/src/lib/postcard-canvas.ts @@ -112,22 +112,39 @@ function stampLines(ctx: CanvasRenderingContext2D, name: string, maxWidth: numbe * **CORS 없이 다시 받아 미리보기만** 세운다(저장·공유는 부르는 쪽이 막는다). * ★ **2026-09-15 에 근본을 고쳤다** — 굽는 쪽이 사진을 내려받아 `s//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((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((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}; } /** 이 사이트가 쓰는 세리프를 그대로 쓴다 — 화면의 카드와 그림의 글씨가 갈리지 않게. */ diff --git a/solution/site/src/sections/items/PostcardMakerSection.tsx b/solution/site/src/sections/items/PostcardMakerSection.tsx index 9ef6843..58d3e4a 100644 --- a/solution/site/src/sections/items/PostcardMakerSection.tsx +++ b/solution/site/src/sections/items/PostcardMakerSection.tsx @@ -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 로 내렸다. */} -
+ ★ 폭은 두 번 왔다 갔다 했다(2026-09-15): 320 → 240(너무 작다) → **360**. + 기준은 "한 화면에서 엽서와 입력칸이 나란히 보이는 가장 큰 크기" 다. */} +
-
-
+ {/* ★ 이 사이트가 쓰는 캐러셀을 그대로 쓴다 (2026-09-15 대표: "왜 이미지 리스트에 + slide js 안 걸려 있음?"). 처음엔 `.slider-viewport`·`.slider-track` 마크업만 + 손으로 찍었는데, 그 둘은 **스크립트가 붙기 전의 기본 상태**다(Carousel.tsx 주석) + — 손가락·트랙패드로만 밀리고 휠 마우스로는 옆에 더 있는 줄도 모른다. */} +
+ {photos.map((photo, i) => ( ))} -
+