Skip to content
121 changes: 85 additions & 36 deletions app/webview/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
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";
import styled from "styled-components/native";

Check warning on line 17 in app/webview/[slug].tsx

View workflow job for this annotation

GitHub Actions / android-check

Using exported name 'styled' as identifier for default import

const webviewUrl =
process.env.EXPO_PUBLIC_WEBVIEW_URL || "https://develop.moadong.com";
Expand Down Expand Up @@ -49,13 +50,11 @@
const {
slug,
path,
url: urlParam,
title,
hideHeader,
} = useLocalSearchParams<{
slug?: string;
path?: string;
url?: string;
title?: string;
hideHeader?: string;
}>();
Expand All @@ -67,15 +66,61 @@

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<string | null>(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();

Expand All @@ -98,11 +143,12 @@
};

const { handleMessage } = useWebViewMessageHandler({
host: 'webview_slug',
onNavigateBack: handleBack,
onNavigateWebview: handleNavigateWebview,
});

if (!config && !path && !urlParam) {
if (!config && !path) {
return (
<Container edges={["top", "bottom"]}>
<Header>
Expand Down Expand Up @@ -150,29 +196,32 @@
</LoadingContainer>
)}

<StyledWebView
source={{ uri: url }}
userAgent={userAgent}
onMessage={handleMessage}
onLoadStart={() => {
if (!hasLoadedOnce) {
setLoading(true);
setError(false);
}
}}
onLoadEnd={() => {
setLoading(false);
if (!hasLoadedOnce) {
setHasLoadedOnce(true);
}
}}
onError={() => {
setError(true);
setLoading(false);
}}
javaScriptEnabled={true}
domStorageEnabled={true}
/>
{tokenResolved && (
<StyledWebView
source={{ uri: url }}
userAgent={userAgent}
injectedJavaScriptBeforeContentLoaded={injectedToken}
onMessage={handleMessage}
onLoadStart={() => {
if (!hasLoadedOnce) {
setLoading(true);
setError(false);
}
}}
onLoadEnd={() => {
setLoading(false);
if (!hasLoadedOnce) {
setHasLoadedOnce(true);
}
}}
onError={() => {
setError(true);
setLoading(false);
}}
javaScriptEnabled={true}
domStorageEnabled={true}
/>
)}
</WebViewWrapper>

</Container>
Expand Down
7 changes: 6 additions & 1 deletion constants/eventname.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ export const PAGE_VIEW_EVENT = {
MAIN_PAGE: 'MainPage',
SUBSCRIBE_PAGE: 'SubscribePage',
MORE_PAGE: 'MorePage',
}
}

// 앱 자체 진단용 — 사용자 행동이 아니라 웹↔앱 브리지 상태를 관측한다.
export const DIAGNOSTIC_EVENT = {
BRIDGE_UNKNOWN_MESSAGE: 'Bridge UnknownMessage',
}
7 changes: 6 additions & 1 deletion hooks/use-webview-message-handler.ts
Original file line number Diff line number Diff line change
@@ -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;
// 웹뷰 내 화면 이동 요청 시 호출
Expand All @@ -21,6 +24,7 @@ interface UseWebViewMessageHandlerOptions {

// WebView 메시지를 처리하는 Hook
export const useWebViewMessageHandler = ({
host,
onNavigateBack,
onNavigateWebview,
onSubscribe,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions ui/club-detail/club-detail-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
42 changes: 23 additions & 19 deletions ui/home/home-webview-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -158,6 +145,9 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) {
payload: { version: Constants.expoConfig?.version ?? 'unknown' },
});
break;

default:
reportUnknownBridgeMessage(type, 'home');
}
} catch {
// 파싱 실패 무시
Expand Down Expand Up @@ -191,23 +181,33 @@ 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 상태로 구분
const isUserInitiated = Platform.OS === 'ios'
? 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, 없으면 기본 동작(종료)
Expand Down Expand Up @@ -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
/>
Expand Down
64 changes: 64 additions & 0 deletions utils/webview.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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));
};
Loading