diff --git a/app/webview/[slug].tsx b/app/webview/[slug].tsx index 6cf9104..8561912 100644 --- a/app/webview/[slug].tsx +++ b/app/webview/[slug].tsx @@ -6,10 +6,11 @@ import { MoaText } from "@/components/moa-text"; import { useMixpanelContext } from "@/contexts/mixpanel-context"; import { useWebViewMessageHandler } from "@/hooks/use-webview-message-handler"; -import { appendSessionId, getWebViewUserAgent } from "@/utils/webview"; +import { ensureAccessToken } from "@/services/auth-token.service"; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin } from "@/utils/webview"; import { Ionicons } from "@expo/vector-icons"; import { useLocalSearchParams, useRouter } from "expo-router"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, TouchableOpacity } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { WebView } from "react-native-webview"; @@ -49,13 +50,11 @@ export default function WebViewScreen() { const { slug, path, - url: urlParam, title, hideHeader, } = useLocalSearchParams<{ slug?: string; path?: string; - url?: string; title?: string; hideHeader?: string; }>(); @@ -67,15 +66,61 @@ export default function WebViewScreen() { const config = pageConfig[slug || ""]; - const baseUrl = urlParam - ? String(urlParam) - : path - ? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}` - : config - ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) - : ""; + /** + * 목적지는 path 또는 pageConfig 로만 정한다. 예전에는 url 파라미터를 그대로 받았는데, + * 그러면 moadongapp://webview/x?url=... 딥링크로 임의 사이트를 이 화면에 띄울 수 있다. + * 이 화면은 onMessage 가 붙어 있어 그 페이지가 앱 브리지를 그대로 쓴다. + */ + const baseUrl = path + ? `${webviewUrl}${String(path).startsWith("/") ? "" : "/"}${String(path)}` + : config + ? (config.url ?? (config.path ? `${webviewUrl}${config.path}` : "")) + : ""; - const url = useMemo(() => appendSessionId(baseUrl, sessionId), [baseUrl, sessionId]); + /** + * session_id 는 웹 Mixpanel 의 distinct_id 라 모아동 밖으로 나가면 안 된다. + * 이 화면은 slug=external 로 임의 외부 URL 도 로드하므로 오리진을 확인하고 붙인다. + */ + const url = useMemo( + () => (isWebViewOrigin(baseUrl) ? appendSessionId(baseUrl, sessionId) : baseUrl), + [baseUrl, sessionId], + ); + + /** + * 우체통은 앱이 주입한 학생 토큰을 먼저 쓴다(웹 studentFetch). 주입이 없으면 웹이 + * 자체 토큰을 발급해 앱과 신원이 갈리고, 답장 푸시로 열린 편지함이 비어 보인다. + * 답장 푸시의 path가 /feedback/letters/... 라 이 화면으로 들어온다. + * + * 주입은 content load 이전에 끝나야 하므로 토큰이 정해질 때까지 웹뷰를 렌더하지 않는다. + * 외부 URL로 진입한 경우에는 주입할 일이 없으니 기다리지도 않는다. + */ + const [studentToken, setStudentToken] = useState(null); + const [tokenResolved, setTokenResolved] = useState(false); + + useEffect(() => { + if (!isWebViewOrigin(baseUrl)) { + setTokenResolved(true); + return; + } + + let cancelled = false; + ensureAccessToken() + .then((token) => { + if (!cancelled) setStudentToken(token); + }) + .catch(() => { + if (!cancelled) setStudentToken(null); + }) + .finally(() => { + if (!cancelled) setTokenResolved(true); + }); + + return () => { + cancelled = true; + }; + }, [baseUrl]); + + const injectedToken = buildStudentTokenInjection(baseUrl, studentToken); const userAgent = getWebViewUserAgent(); @@ -98,11 +143,12 @@ export default function WebViewScreen() { }; const { handleMessage } = useWebViewMessageHandler({ + host: 'webview_slug', onNavigateBack: handleBack, onNavigateWebview: handleNavigateWebview, }); - if (!config && !path && !urlParam) { + if (!config && !path) { return (
@@ -150,29 +196,32 @@ export default function WebViewScreen() { )} - { - if (!hasLoadedOnce) { - setLoading(true); - setError(false); - } - }} - onLoadEnd={() => { - setLoading(false); - if (!hasLoadedOnce) { - setHasLoadedOnce(true); - } - }} - onError={() => { - setError(true); - setLoading(false); - }} - javaScriptEnabled={true} - domStorageEnabled={true} - /> + {tokenResolved && ( + { + if (!hasLoadedOnce) { + setLoading(true); + setError(false); + } + }} + onLoadEnd={() => { + setLoading(false); + if (!hasLoadedOnce) { + setHasLoadedOnce(true); + } + }} + onError={() => { + setError(true); + setLoading(false); + }} + javaScriptEnabled={true} + domStorageEnabled={true} + /> + )} diff --git a/constants/eventname.ts b/constants/eventname.ts index ae8a21b..668ec7a 100644 --- a/constants/eventname.ts +++ b/constants/eventname.ts @@ -18,4 +18,9 @@ export const PAGE_VIEW_EVENT = { MAIN_PAGE: 'MainPage', SUBSCRIBE_PAGE: 'SubscribePage', MORE_PAGE: 'MorePage', -} \ No newline at end of file +} + +// 앱 자체 진단용 — 사용자 행동이 아니라 웹↔앱 브리지 상태를 관측한다. +export const DIAGNOSTIC_EVENT = { + BRIDGE_UNKNOWN_MESSAGE: 'Bridge UnknownMessage', +} diff --git a/hooks/use-webview-message-handler.ts b/hooks/use-webview-message-handler.ts index 4b7143f..19ff45b 100644 --- a/hooks/use-webview-message-handler.ts +++ b/hooks/use-webview-message-handler.ts @@ -1,8 +1,11 @@ import { WebViewMessage, WebViewMessageEvent, WebViewMessageTypes } from '@/types/webview-message.types'; +import { reportUnknownBridgeMessage } from '@/utils/webview'; import { useCallback } from 'react'; import { Linking } from 'react-native'; interface UseWebViewMessageHandlerOptions { + /** 처리되지 않은 메시지를 어느 화면이 받았는지 구분하기 위한 이름 */ + host: string; // 뒤로가기 요청 시 호출 onNavigateBack?: () => void; // 웹뷰 내 화면 이동 요청 시 호출 @@ -21,6 +24,7 @@ interface UseWebViewMessageHandlerOptions { // WebView 메시지를 처리하는 Hook export const useWebViewMessageHandler = ({ + host, onNavigateBack, onNavigateWebview, onSubscribe, @@ -83,12 +87,13 @@ export const useWebViewMessageHandler = ({ ); break; default: - console.warn('[WebViewHandler] 알 수 없는 메시지 타입:', message); + reportUnknownBridgeMessage((message as { type?: unknown }).type, host); } } catch (error) { console.error('[WebViewHandler] 메시지 파싱 오류:', error); } }, [ + host, onNavigateBack, onNavigateWebview, onSubscribe, diff --git a/ui/club-detail/club-detail-screen.tsx b/ui/club-detail/club-detail-screen.tsx index 28d998b..689540f 100644 --- a/ui/club-detail/club-detail-screen.tsx +++ b/ui/club-detail/club-detail-screen.tsx @@ -114,6 +114,7 @@ export default function ClubWebViewScreen() { // WebView 메시지 핸들러 const { handleMessage } = useWebViewMessageHandler({ + host: 'club_detail', onNavigateBack: handleBack, onSubscribe: async (targetId: string, clubName?: string) => { trackEvent(USER_EVENT.SUBSCRIBE_BUTTON_CLICKED, { diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index b0975d5..392171b 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -2,7 +2,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-co import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; import { ensureAccessToken } from '@/services/auth-token.service'; -import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; +import { appendSessionId, buildStudentTokenInjection, getWebViewUserAgent, isWebViewOrigin, reportUnknownBridgeMessage } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; import * as WebBrowser from 'expo-web-browser'; @@ -61,20 +61,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const url = sessionLoading || !tokenResolved ? null : appendSessionId(BASE_URL, sessionId); - // 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로, - // origin 가드 없이는 외부 사이트로 이동했을 때 베어러 토큰이 노출된다. - // origin 비교는 웹뷰 안에서 한다. RN 의 URL 폴리필은 호스트 대소문자와 기본 포트를 - // 정규화하지 않아 window.location.origin 과 어긋날 수 있다. 파싱에 실패하면 주입하지 않는다. - const injectedToken = studentToken - ? `(function(){ - try { - if (new URL(${JSON.stringify(BASE_URL)}).origin !== window.location.origin) return; - } catch (e) { - return; - } - window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(studentToken)}; - })(); true;` - : undefined; + const injectedToken = buildStudentTokenInjection(BASE_URL, studentToken); useEffect(() => { if (url) { @@ -158,6 +145,9 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { payload: { version: Constants.expoConfig?.version ?? 'unknown' }, }); break; + + default: + reportUnknownBridgeMessage(type, 'home'); } } catch { // 파싱 실패 무시 @@ -191,8 +181,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const handleShouldStartLoadWithRequest = useCallback( (request: ShouldStartLoadRequest) => { - const baseOrigin = (process.env.EXPO_PUBLIC_WEBVIEW_URL ?? 'https://moadong.com').replace(/\/$/, ''); - if (request.url.startsWith('http') && !request.url.startsWith(baseOrigin)) { + // origin 을 문자열 prefix 로 판정하면 moadong.com.evil.com 이 내부로 통과한다. + // 그 페이지가 이 웹뷰에 뜨면 window.ReactNativeWebView.postMessage 로 브리지를 + // 그대로 쓸 수 있다(SUBSCRIBE_TOGGLE, OPEN_EXTERNAL_URL 등). 파싱해서 비교한다. + if (request.url.startsWith('http') && !isWebViewOrigin(request.url)) { // iOS: navigationType === 'click' 은 사용자가 직접 링크를 탭한 경우만 해당 // 초기 로드·서버 리다이렉트는 'other' 이므로 인터셉트하지 않음 // Android: navigationType이 항상 'other'이므로 loaded 상태로 구분 @@ -200,14 +192,22 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { ? request.navigationType === 'click' : loaded; if (isUserInitiated) { - router.push({ pathname: '/webview/[slug]', params: { slug: 'external', url: request.url } }); + // 외부 사이트는 OS 브라우저로 넘긴다. 앱 화면(WebView)에 띄우면 모아동 헤더가 + // 붙어 어디인지 구분이 안 되고, 앱 프로세스 안이라 그 페이지가 + // window.ReactNativeWebView 로 브리지를 쓸 수 있다. + // 배너·동아리 SNS·OPEN_EXTERNAL_URL 이 이미 같은 방식이다. + WebBrowser.openBrowserAsync(request.url, { + presentationStyle: WebBrowser.WebBrowserPresentationStyle.AUTOMATIC, + }).catch((error) => { + console.warn('[HomeWebView] 외부 링크 열기 실패:', request.url, error); + }); return false; } return true; } return true; }, - [router, loaded], + [loaded], ); // Android 하드웨어 뒤로가기: 웹뷰 히스토리가 있으면 웹뷰 back, 없으면 기본 동작(종료) @@ -247,6 +247,10 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { onHttpError={handleError} javaScriptEnabled domStorageEnabled + // Android 기본값(true)이면 target=_blank 가 onCreateWindow 로 가는데, + // onOpenWindow 핸들러가 없으면 화면에 붙지 않는 WebView 로 빨려들어가 링크가 죽는다. + // false 로 두면 같은 요청이 onShouldStartLoadWithRequest 를 타 iOS 와 같은 경로가 된다. + setSupportMultipleWindows={false} pullToRefreshEnabled allowsBackForwardNavigationGestures /> diff --git a/utils/webview.ts b/utils/webview.ts index 7d6f847..e50721b 100644 --- a/utils/webview.ts +++ b/utils/webview.ts @@ -1,3 +1,5 @@ +import { DIAGNOSTIC_EVENT } from '@/constants/eventname'; +import { getMixpanel } from '@/utils/mixpanel'; import Constants from 'expo-constants'; import { Platform } from 'react-native'; @@ -12,3 +14,65 @@ export const appendSessionId = (url: string, sessionId: string): string => { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}session_id=${encodeURIComponent(sessionId)}`; }; + +const WEBVIEW_URL = process.env.EXPO_PUBLIC_WEBVIEW_URL || 'https://moadong.com'; + +/** 웹뷰가 로드할 URL이 모아동 오리진인지. 학생 토큰을 주입해도 되는지의 기준이다. */ +export const isWebViewOrigin = (url: string): boolean => { + try { + return new URL(url).origin === new URL(WEBVIEW_URL).origin; + } catch { + return false; + } +}; + +/** + * 웹이 첫 API 호출 시점에 앱과 같은 학생 신원을 쓰도록 토큰을 주입하는 스크립트. + * + * 가드가 두 겹이다. + * 1. 진입 URL이 외부일 수 있어(webview/[slug]의 external) RN 쪽에서 먼저 막는다. + * 비교 기준은 진입 URL이 아니라 모아동 URL이어야 한다 - 진입 URL끼리 비교하면 + * 외부 URL로 진입했을 때 "외부 == 외부"로 통과해 버린다. + * 2. 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로 스크립트 안에서도 + * 실행 시점 origin을 다시 본다. 없으면 외부 사이트로 이동했을 때 토큰이 노출된다. + * + * 2번의 origin 비교는 웹뷰 안에서 한다. RN의 URL 폴리필은 호스트 대소문자와 기본 포트를 + * 정규화하지 않아, RN에서 만든 origin 문자열이 window.location.origin과 어긋날 수 있다. + * 그래서 모아동 URL을 그대로 넘겨 웹뷰의 URL로 파싱한다. 파싱에 실패하면 주입하지 않는다. + */ +export const buildStudentTokenInjection = ( + targetUrl: string, + token: string | null, +): string | undefined => { + if (!token || !isWebViewOrigin(targetUrl)) return undefined; + + return `(function(){ + try { + if (new URL(${JSON.stringify(WEBVIEW_URL)}).origin !== window.location.origin) return; + } catch (e) { + return; + } + window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(token)}; + })(); true;`; +}; + +// 웹이 앱보다 앞서 나가 새 메시지를 추가했는데 호스트가 그걸 모르는 상황을 관측한다. +// 각 호스트 switch의 default에서만 부르므로, 그 호스트가 실제로 처리하지 못한 것만 잡힌다. +// 리포팅 실패가 메시지 처리를 막아선 안 되므로 예외를 밖으로 내보내지 않는다. +export const reportUnknownBridgeMessage = (type: unknown, host: string): void => { + // 서드파티가 쏜 비정형 메시지를 걸러내기 위해 문자열 type만 취급한다. + if (typeof type !== 'string') return; + + console.warn(`[WebViewBridge] 처리되지 않은 메시지: ${type} (host: ${host})`); + + getMixpanel() + .then(mixpanel => + mixpanel?.track(DIAGNOSTIC_EVENT.BRIDGE_UNKNOWN_MESSAGE, { + message_type: type, + host, + app_version: Constants.expoConfig?.version || '1.0.0', + platform: Platform.OS, + }), + ) + .catch(err => console.error('[WebViewBridge] 리포팅 실패:', err)); +};