From 50cc45731cae6fdd1db7daf81c12c0cc728adb74 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 22:49:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(b08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EA=B2=BD=EB=A1=9C=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=ED=85=8C=EC=9D=BC=EC=8A=A4=EC=BC=80=EC=9D=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=20=EC=95=88=EB=82=B4=20(244-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보안 문맥 아닌 주소(getDisplayMedia 없음) = [지도 캡처] 끔 + 까닭 한 줄 「캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요」 - SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 필요 안내 한 줄 · 사진 올리기 - 새 시험 test_244_10 · test_244_9 에 보안 문맥 navigator 가짜 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../B08_Quantity_UI_Milestone_KakaoMap.ts | 26 ++++- .../test_244_10_b08_milestone_tailscale.py | 109 ++++++++++++++++++ .../test_244_9_b08_milestone_route_map.py | 2 + 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_244_10_b08_milestone_tailscale.py diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index 7bbdcb75e..196c5539b 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -8,6 +8,8 @@ * 시점(`mapMarks`) · 경로 전체 맞춤 * [지도 캡처] = getDisplayMedia(preferCurrentTab) → 지도 칸만(CropTarget · 없으면 `cropBox` 자리 수치) → * 그 경로 그림(244-1 그림 자리 · 올리는 즉시 서버) · 트랙 곧바로 멈춤 + * (244-10) 보안 문맥 아닌 주소(테일스케일 http IP 등) = getDisplayMedia 없음 → [지도 캡처] 끔 + 까닭 한 줄 · + * SDK 못 받음(도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; @@ -22,6 +24,8 @@ import { export const NO_MAP = "카카오 JavaScript 키 · 도메인 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요"; +export const NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요"; +const SDK_FAIL = "KakaoSdkFail"; const SDK = "https://dapi.kakao.com/v2/maps/sdk.js"; const LINE_COLOR = "#E60046"; // 테마 무관 또렷한 길 선 @@ -63,7 +67,15 @@ export function loadKakaoMaps(projectId: string): Promise { const script = document.createElement("script"); script.src = `${SDK}?appkey=${encodeURIComponent(key)}&autoload=false`; script.onload = () => done(); - script.onerror = () => fail(new Error(NO_MAP)); + script.onerror = () => + fail( + Object.assign( + new Error( + `카카오 JavaScript 키 도메인에 지금 주소(${location.origin}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요`, + ), + { name: SDK_FAIL }, + ), + ); document.head.append(script); }); } @@ -142,6 +154,8 @@ export function routeMapView( let map: KMap | null = null; let drawn: KOverlay[] = []; let shown = ""; // 같은 표지 · 길 선이면 다시 안 그림(사용자가 옮긴 지도 자리 그대로) + const canCapture = Boolean(navigator.mediaDevices?.getDisplayMedia); // http IP 주소 = 없음 + const shot = canCapture ? "[지도 캡처] = 이 지도 칸 → 경로 그림" : NO_CAPTURE; const draw = (): void => { const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null); @@ -192,13 +206,13 @@ export function routeMapView( map.setBounds(bounds); shown = sig; hint.textContent = lines.length - ? "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" - : "[길찾기]를 누르면 길 선이 그려짐 · [지도 캡처] = 이 지도 칸 → 경로 그림"; + ? `길 선 = 카카오 길찾기 · ${shot}` + : `[길찾기]를 누르면 길 선이 그려짐 · ${shot}`; }), - () => { + (error: unknown) => { box.hidden = true; // 키 없음 · SDK 못 받음 — 안내 한 줄 · [캡처] 숨김(사진 올리기는 그대로) capture.hidden = true; - hint.textContent = NO_MAP; + hint.textContent = (error as Error)?.name === SDK_FAIL ? (error as Error).message : NO_MAP; }, ); }; @@ -207,6 +221,7 @@ export function routeMapView( label: "지도 캡처", size: "sm", variant: "ghost", + disabled: !canCapture, onClick: () => void captureElement(box) .then((file) => uploadImage(projectId, routeId, file)) @@ -216,6 +231,7 @@ export function routeMapView( }), }); capture.dataset.b08MilestoneCapture = ""; + if (!canCapture) capture.title = NO_CAPTURE; const root = el("div", { className: "b08-milestone__map-wrap", children: [el("div", { className: "b08-milestone__bar", children: [capture] }), hint, box], diff --git a/resources/tester/test_244_10_b08_milestone_tailscale.py b/resources/tester/test_244_10_b08_milestone_tailscale.py new file mode 100644 index 000000000..36226bd47 --- /dev/null +++ b/resources/tester/test_244_10_b08_milestone_tailscale.py @@ -0,0 +1,109 @@ +"""244-10 B08 거리이정표 경로 지도 — 테일스케일 주소(http://100.72.32.27:5173 · 보안 문맥 아님). + +① getDisplayMedia 없음 = [지도 캡처] 끔 + 까닭 한 줄(지도는 그대로) +② SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 안내 한 줄 · 지도 · 캡처 숨김 +(가짜 kakao.maps · document 로 node 에서 돌림) +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +LAYOUT = "B08_Quantity/B08_Quantity_Milestone_Layout.ts" +KMAP = "B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts" +ORIGIN = "http://100.72.32.27:5173" +NO_CAPTURE = "캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +STUBS = f""" +const el = (tag: string, o: any = {{}}) => ({{ tag, hidden: false, textContent: o.text ?? "", + className: o.className, dataset: {{}}, children: o.children ?? [] }}); +const createButton = (o: any) => ({{ ...el("button", {{ text: o.label }}), disabled: !!o.disabled, + title: "", onClick: o.onClick }}); +const showToast = () => {{}}; +const uploadImage = async () => {{}}; +const requestAnimationFrame = (f: () => void) => setTimeout(f, 0); +const request = async () => ({{ key: "fake-js-key" }}); +const injected: string[] = []; +(globalThis as any).window = globalThis; +(globalThis as any).location = {{ origin: {json.dumps(ORIGIN)} }}; +const liveMilestone = () => ({{ site: {{}}, base: {{ enabled: false }}, + routes: [{{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: false }}] }}); +const siteInfo = () => ({{ start_lonlat: [127.83, 35.64] }}); +const routePaths = () => [[[35.68, 127.9], [35.64, 127.83]]]; +""" + +FAKE_KAKAO = """ +class Fake { setMap() {} relayout() {} setBounds() {} extend() {} } +(globalThis as any).kakao = { maps: { load: (f: () => void) => f(), LatLng: Fake, + LatLngBounds: Fake, Map: Fake, Polyline: Fake, Marker: Fake, CustomOverlay: Fake } }; +""" + + +def _view(setup): + script = ( + STUBS + + setup + + IMPORTS.sub("", _read(KMAP)) + + """ +const view = routeMapView("p1", "r1"); +await new Promise((r) => setTimeout(r, 30)); +const [bar, hint, box] = view.root.children; +const cap = bar.children[0]; +console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hidden, + capHidden: cap.hidden, capDisabled: cap.disabled, capTitle: cap.title })); +""" + ) + src = IMPORTS.sub("", _read(LAYOUT)) + script + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +NO_MEDIA = """ +Object.defineProperty(globalThis, "navigator", { configurable: true, value: {} }); +(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } }; +""" + + +def test_보안_문맥_아님_캡처_끔_까닭_한_줄(): + got = _view(NO_MEDIA + FAKE_KAKAO) + assert not got["boxHidden"] and not got["capHidden"] # 지도는 그대로 뜸 + assert got["capDisabled"] and got["capTitle"] == NO_CAPTURE + assert got["hint"] == f"길 선 = 카카오 길찾기 · {NO_CAPTURE}" + assert "[지도 캡처] =" not in got["hint"] + + +def test_보안_문맥_캡처_켜짐(): + media = """ +Object.defineProperty(globalThis, "navigator", { configurable: true, + value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); +(globalThis as any).document = { createElement: () => ({}), head: { append: () => {} } }; +""" + got = _view(media + FAKE_KAKAO) + assert not got["capDisabled"] and got["capTitle"] == "" + assert got["hint"] == "길 선 = 카카오 길찾기 · [지도 캡처] = 이 지도 칸 → 경로 그림" + + +def test_SDK_401_도메인_등록_안내(): + sdk_401 = """ +(globalThis as any).document = { createElement: () => ({}), + head: { append: (s: any) => (injected.push(s.src), setTimeout(() => s.onerror(), 0)) } }; +""" + got = _view(NO_MEDIA + sdk_401) # window.kakao 없음 → script 넣음 → 401 = onerror + assert len(got["injected"]) == 1 and "sdk.js?appkey=fake-js-key" in got["injected"][0] + assert got["hint"] == ( + f"카카오 JavaScript 키 도메인에 지금 주소({ORIGIN}) 등록 필요 — 지도 대신 [그림 올리기]로 넣으세요" + ) + assert got["boxHidden"] and got["capHidden"] diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 3afc3857a..bedc3e1ef 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -152,6 +152,8 @@ const injected: string[] = []; (globalThis as any).document = { createElement: () => ({}), head: { append: (s: any) => injected.push(s.src) } }; (globalThis as any).window = globalThis; +Object.defineProperty(globalThis, "navigator", { configurable: true, + value: { mediaDevices: { getDisplayMedia: async () => ({}) } } }); // localhost = 보안 문맥 const liveMilestone = () => ({ site: {}, base: { enabled: true, name: "병곡리", lon: 127.85, lat: 35.66 }, routes: [{ id: "r1", name: "거창군청", lon: 127.9, lat: 35.68, via_base: true }] }); const siteInfo = () => ({ start_lonlat: [127.83, 35.64] });