Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 84 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 @@ -102,7 +147,7 @@
onNavigateWebview: handleNavigateWebview,
});

if (!config && !path && !urlParam) {
if (!config && !path) {
return (
<Container edges={["top", "bottom"]}>
<Header>
Expand Down Expand Up @@ -150,29 +195,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
39 changes: 20 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 } 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 @@ -187,23 +174,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 @@ -243,6 +240,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
41 changes: 41 additions & 0 deletions utils/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,44 @@ 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;`;
};
Loading