From ca3dba16ccd8620253d10694e8da5984e933f11e Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 27 Jul 2026 14:40:14 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=EB=8A=94=20=EC=9B=B9=EC=95=B1=20=EC=85=B8=EC=9D=84=20?= =?UTF-8?q?=EB=82=B4=EB=A0=A4=EC=A4=80=EB=8B=A4=20=E2=80=94=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=A3=BC=EC=86=8C=EC=97=90=EC=84=9C=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=20=EC=9B=B9=EC=95=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '웹에서 계속 보기' 2차 CTA 로 한 번 더 누르게 하던 것을, 링크를 누르면 **바로** 웹앱이 뜨도록 바꾼다(pmdart docs/web-port.md 결정 6). 웹앱 주소를 그냥 공유하지 않는 이유는 그러면 크롤러가 빈 셸을 받아 링크 미리보기가 죽기 때문이다. 그래서 주소는 go.pawmate.kr/s 그대로 두고, 이 함수가 웹앱의 index.html 을 가져와 아래를 주입해 내려준다. 1) 를 웹앱 절대주소로 — 에셋을 그쪽에서 받는다(CORS 열려 있음) 2) 토큰별 OG 태그(제목·설명·이미지·url) + 3) <meta name="pm-post"> — 열어야 할 게시글 id. 주소에는 토큰만 있고 토큰→게시글 해석은 service_role 전용이라 웹앱이 스스로 못 한다. 크롤러는 OG 만 읽고, 사람은 같은 주소에서 웹앱이 부팅된다. 셸을 못 가져오면 (웹앱 배포 중 등) 기존 서버 렌더링으로 폴백한다 — 링크가 죽는 것보다 낫다. WEB_APP_URL 미설정이면 지금까지와 동일하게 동작한다. 케어리포트·업체 미리보기는 이 경로를 쓰지 않는다. 그쪽 수신자는 '내 아이 기록을 받을 보호자' 한 명이라 웹 탐색이 아니라 앱 설치·계정 연결(전화번호 대조 자동 연결)이 목적이고, 그 연결은 앱에만 있다. deno check 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- supabase/functions/share-view/index.ts | 69 ++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/supabase/functions/share-view/index.ts b/supabase/functions/share-view/index.ts index 13158a7..015ad31 100644 --- a/supabase/functions/share-view/index.ts +++ b/supabase/functions/share-view/index.ts @@ -169,12 +169,71 @@ function page(title: string, ogDesc: string, inner: string): string { /// '웹에서 계속 보기' CTA — 웹앱 주소가 설정됐고 게시글 id 가 있을 때만. /// 앱 설치 없이 브라우저에서 계속 둘러보게 하는 2차 동선이다(1차는 스토어). /// 계측은 기존 share_view 이벤트가 이미 이 페이지 열람 시점에 기록한다. +/// +/// 게시글은 셸 서빙(appShell)으로 대체됐고, 남은 종류(스타터 등)를 위해 둔다. function webCta(postId: unknown): string { if (!WEB_APP_URL || typeof postId !== "string" || !postId) return ""; return `<a class="cta sub" href="${esc(WEB_APP_URL)}/p/${esc(postId)}" rel="noopener">웹에서 계속 보기</a>`; } +/// **게시글 공유 = 웹앱 셸을 그대로 내려준다**(pmdart docs/web-port.md 결정 6). +/// +/// 같은 주소(go.pawmate.kr/s?t=…)에서 크롤러는 OG 태그만 읽고, 사람은 웹앱이 +/// 부팅돼 바로 둘러보기로 이어진다. 웹앱 주소를 직접 공유하지 않는 이유는 +/// 그러면 크롤러가 빈 셸을 받아 **링크 미리보기가 죽기** 때문이다. +/// +/// 구현: 웹앱의 index.html 을 가져와 (1) `<base href>` 를 웹앱 절대주소로 바꿔 +/// 에셋을 그쪽에서 받게 하고, (2) 토큰별 OG 태그와 (3) 열어야 할 게시글 id 를 +/// `<meta name="pm-post">` 로 심는다. 주소에는 토큰만 있고 토큰→게시글 해석은 +/// service_role 전용이라 웹앱이 스스로 못 하기 때문이다. +/// +/// 셸을 못 가져오면(웹앱 배포 중 등) null 을 돌려 기존 서버 렌더링으로 폴백한다 +/// — 공유 링크가 죽는 것보다 낫다. +/// +/// 케어리포트·업체 미리보기는 이 경로를 쓰지 않는다. 그쪽 수신자는 '내 아이 기록을 +/// 받을 보호자' 한 명이라 웹 탐색이 아니라 앱 설치·계정 연결이 목적이다. +async function appShell( + postId: unknown, + ogTitle: string, + ogDesc: string, + ogImage: string | null, +): Promise<string | null> { + if (!WEB_APP_URL || typeof postId !== "string" || !postId) return null; + let shell: string; + try { + const res = await fetch(`${WEB_APP_URL}/index.html`, { + headers: { "cache-control": "no-cache" }, + }); + if (!res.ok) return null; + shell = await res.text(); + } catch (e) { + console.error("appShell: 셸을 가져오지 못함 — 서버 렌더링으로 폴백", e); + return null; + } + // 빌드가 <base href="/"> 로 나오므로 절대주소로 교체 — 에셋(main.dart.js, + // canvaskit.wasm 등)을 웹앱 도메인에서 받는다(그쪽에 CORS 허용 헤더가 있다). + shell = shell.replace( + /<base href="[^"]*">/, + `<base href="${esc(WEB_APP_URL)}/">`, + ); + // 기본 OG 를 이 게시글 것으로 교체(없으면 그대로 두고 아래에서 추가). + shell = shell + .replace(/<meta property="og:title"[^>]*>/, "") + .replace(/<meta property="og:description"[^>]*>/, "") + .replace(/<meta property="og:image"[^>]*>/, ""); + const inject = ` +<meta property="og:title" content="${esc(ogTitle)}"> +<meta property="og:description" content="${esc(ogDesc)}"> +${ogImage ? `<meta property="og:image" content="${esc(ogImage)}">` : ""} +<meta property="og:url" content="${esc(WEB_APP_URL)}/p/${esc(postId)}"> +<meta name="pm-post" content="${esc(postId)}"> +<title>${esc(ogTitle)} — PawMate`; + // 기존 은 지우고(중복 방지) </head> 앞에 한 번에 넣는다. + shell = shell.replace(/<title>[\s\S]*?<\/title>/, ""); + return shell.replace("</head>", `${inject}\n</head>`); +} + function noticePage(title: string, msg: string, status: number): Response { return html( page(title, msg, `<div class="notice"><h1>${esc(title)}</h1><p>${esc(msg)}</p></div>`), @@ -295,6 +354,16 @@ Deno.serve(async (req) => { const isVideo = String(po.image_mime ?? "").startsWith("video/"); const mediaUrl = po.image_url ? String(po.image_url) : null; const thumbUrl = po.image_thumb_url ? String(po.image_thumb_url) : null; + + // 웹앱 셸 우선 — 같은 주소에서 크롤러는 OG 만, 사람은 웹앱이 뜬다. + // WEB_APP_URL 미설정이거나 셸을 못 가져오면 아래 서버 렌더링으로 폴백한다. + const shell = await appShell( + po.id, + title, + `${author} · ${catLabel} — PawMate`, + isVideo ? thumbUrl : mediaUrl, + ); + if (shell) return html(shell); const d = new Date(String(po.created_at ?? "")); const dateStr = isNaN(d.getTime()) ? ""