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
78 changes: 32 additions & 46 deletions app/(tabs)/(home)/add-link.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Linking,
Platform,
StyleSheet,
Text,
Expand All @@ -15,14 +13,35 @@ import { Stack, router } from 'expo-router';
import { ScanButton } from '@/components/ui/scan-button';
import { Colors, Typography } from '@/constants/theme';

function getRawHostname(value: string): string | null {
const authority = value.match(/^https?:\/\/([^/?#]+)/i)?.[1];
if (!authority) return null;

return authority.split('@').pop()?.split(':')[0] ?? null;
}

function isValidHostname(hostname: string): boolean {
const labels = hostname.split('.');
if (labels.length < 2) return false;
if (labels.some((label) => !label)) return false;

const tld = labels[labels.length - 1];
if (!/^[a-z]{2,}$/i.test(tld)) return false;

return labels.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label));
}

function isValidUrlFormat(value: string): boolean {
const trimmed = value.trim();
if (!/^https?:\/\//i.test(trimmed)) return false;
if (!trimmed || /\s/.test(trimmed)) return false;

const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const rawHostname = getRawHostname(candidate);
if (!rawHostname || !isValidHostname(rawHostname)) return false;

try {
const parsed = new URL(trimmed);
const parts = parsed.hostname.split('.');
const tld = parts[parts.length - 1];
return parts.length >= 2 && tld.length >= 2;
const parsed = new URL(candidate);
return isValidHostname(parsed.hostname);
} catch {
return false;
}
Expand All @@ -31,37 +50,22 @@ function isValidUrlFormat(value: string): boolean {
export default function AddLinkScreen() {
const [url, setUrl] = useState('');
const [error, setError] = useState('');
const [isChecking, setIsChecking] = useState(false);

const handleScan = async () => {
const handleScan = () => {
const trimmed = url.trim();

if (!trimmed) {
setError('URL을 입력해주세요.');
return;
}

// 1단계: new URL()로 형식 검증
if (!isValidUrlFormat(trimmed)) {
setError('올바르지 않은 URL 입력입니다.');
return;
}

// 2단계: Linking.canOpenURL()로 실제 열기 가능 여부 확인
setIsChecking(true);
try {
const canOpen = await Linking.canOpenURL(trimmed);
if (!canOpen) {
setError('올바르지 않은 URL 입력입니다.');
return;
}
setError('');
router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: trimmed } });
} catch {
setError('URL 확인 중 오류가 발생했습니다.');
} finally {
setIsChecking(false);
}
setError('');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

naver.com 같은 입력을 허용했지만, 여기서 원본 trimmed를 그대로 넘기면 안전 결과 화면의 즉시 URL 접속이 Linking.openURL(url)을 bare domain으로 호출하게 됩니다. React Native의 외부 열기는 https://naver.com처럼 스킴이 있는 URL이 필요해서, 새로 허용한 입력은 검사 후 접속 액션이 실패합니다.

백엔드 전달값은 원본으로 유지하더라도, 실제 열기용 값은 보정해야 할 것 같습니다! 추후에 백엔드에서 넘어오는 값 중에 final_url 혹은 finalUrl 값이 있을텐데 그 값을 사용하면 될 것 같습니다.

그냥 인지만 하고 있어주세요!!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다!

현재 add-link.tsx에서는 백엔드 전달을 고려해서 입력 원본을 유지하고 있는데, 말씀해주신 것처럼 naver.com 같은 bare domain은 결과 화면의 Linking.openURL(url)에서 바로 열 수 없는 점 인지했습니다.

추후 백엔드 연동 시 finalUrl/final_url 값을 열기용 URL로 사용하거나, 실제 접속 액션에서는 스킴이 포함된 URL로 보정하는 방향으로 처리하겠습니다.

router.push({ pathname: '/(tabs)/(home)/scanning', params: { url: trimmed } });
};

const handleChangeUrl = (value: string) => {
Expand All @@ -70,7 +74,7 @@ export default function AddLinkScreen() {
};

const hasError = error.length > 0;
const scanDisabled = !url.trim() || isChecking;
const scanDisabled = !url.trim();

return (
<>
Expand Down Expand Up @@ -118,9 +122,8 @@ export default function AddLinkScreen() {
keyboardType="url"
returnKeyType="search"
onSubmitEditing={handleScan}
editable={!isChecking}
/>
{url.length > 0 && !isChecking && (
{url.length > 0 && (
<TouchableOpacity
onPress={() => { setUrl(''); setError(''); }}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
Expand All @@ -130,13 +133,7 @@ export default function AddLinkScreen() {
</TouchableOpacity>
)}
</View>
{isChecking ? (
<View style={styles.loadingBox}>
<ActivityIndicator size="small" color={Colors.brand.primary} />
</View>
) : (
<ScanButton onPress={handleScan} disabled={scanDisabled} />
)}
<ScanButton onPress={handleScan} disabled={scanDisabled} />
</View>

{/* 에러 메시지 */}
Expand Down Expand Up @@ -232,17 +229,6 @@ const styles = StyleSheet.create({
color: Colors.brand.primary,
lineHeight: 16,
},
loadingBox: {
width: 60,
height: 60,
borderRadius: 16,
borderWidth: 1.5,
borderColor: Colors.brand.line,
backgroundColor: Colors.brand.background,
alignItems: 'center',
justifyContent: 'center',
},

// 에러
errorText: {
...Typography.body,
Expand Down
15 changes: 1 addition & 14 deletions components/ui/card-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,6 @@ const VERDICT_LABELS: Record<LinkVerdict, string> = {

const VERDICT_COLORS: Record<LinkVerdict, { background: string; text: string }> = Colors.brand.verdict;

function normalizeLinkUrl(value?: string | null) {
const trimmed = value?.trim();
if (!trimmed) return null;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}

function getFirstText(...values: (string | null | undefined)[]) {
return values.find((value) => value?.trim())?.trim();
}
Expand All @@ -58,7 +51,7 @@ export function CardLink({
const moreRef = useRef<View>(null);
const displayUrl = getFirstText(finalUrl, originalUrl) ?? 'URL 정보 없음';
const displayTitle = getFirstText(title, summary) ?? '제목 없음';
const openUrl = normalizeLinkUrl(getFirstText(finalUrl, originalUrl));
const openUrl = getFirstText(finalUrl, originalUrl);
const normalizedVerdict = verdict && verdict in VERDICT_LABELS ? verdict : undefined;
const statusLabel = normalizedVerdict ? VERDICT_LABELS[normalizedVerdict] : (getFirstText(label) ?? '결과 없음');
const statusColors = normalizedVerdict ? VERDICT_COLORS[normalizedVerdict] : undefined;
Expand Down Expand Up @@ -88,12 +81,6 @@ export function CardLink({
}

try {
const canOpen = await Linking.canOpenURL(openUrl);
if (!canOpen) {
Alert.alert('URL을 열 수 없어요', '외부 브라우저에서 열 수 없는 주소입니다.');
return;
}

await Linking.openURL(openUrl);
} catch {
Alert.alert('URL을 열 수 없어요', '잠시 후 다시 시도해주세요.');
Expand Down
Loading