diff --git a/apps/admin/src/components/features/scores/GpaScoreTable.tsx b/apps/admin/src/components/features/scores/GpaScoreTable.tsx index 5d3855db..95910858 100644 --- a/apps/admin/src/components/features/scores/GpaScoreTable.tsx +++ b/apps/admin/src/components/features/scores/GpaScoreTable.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { scoreApi } from "@/lib/api/scores"; +import { normalizeImageUrlToUploadCdn } from "@/lib/utils/cdnUrl"; import type { GpaScoreWithUser, VerifyStatus } from "@/types/scores"; import { ScoreVerifyButton } from "./ScoreVerifyButton"; import { StatusBadge } from "./StatusBadge"; @@ -13,8 +14,6 @@ interface Props { verifyFilter: VerifyStatus; } -const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || ""; - export function GpaScoreTable({ verifyFilter }: Props) { const queryClient = useQueryClient(); const [page, setPage] = useState(1); @@ -142,7 +141,7 @@ export function GpaScoreTable({ verifyFilter }: Props) {
프로필 @@ -197,7 +196,7 @@ export function GpaScoreTable({ verifyFilter }: Props) { {score.gpaScoreStatusResponse.rejectedReason || "-"}
프로필 @@ -220,7 +219,9 @@ export function LanguageScoreTable({ verifyFilter }: Props) { {score.languageTestScoreStatusResponse.rejectedReason || "-"} { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + + try { + const parsed = new URL(trimmed); + return `${parsed.protocol}//${parsed.host}`.replace(/\/+$/, ""); + } catch { + return undefined; + } +}; + +const getHostname = (value: string | undefined) => { + if (!value) return undefined; + + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return undefined; + } +}; + +const runtimeEnv = (import.meta as ImportMeta & { env?: Record }).env; +const envUploadOrigin = normalizeOrigin(runtimeEnv?.VITE_UPLOADED_IMAGE_URL); +const envS3BaseOrigin = normalizeOrigin(runtimeEnv?.VITE_S3_BASE_URL); +const uploadOrigin = envUploadOrigin ?? UPLOAD_CDN_ORIGIN; + +const cdnHostnames = new Set( + [UPLOAD_CDN_HOSTNAME, DEFAULT_CDN_HOSTNAME, getHostname(envUploadOrigin), getHostname(envS3BaseOrigin)].filter( + (hostname): hostname is string => Boolean(hostname), + ), +); + +const joinUploadOrigin = (path: string, search = "", hash = "") => { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${uploadOrigin}${normalizedPath}${search}${hash}`; +}; + +const getLegacyS3ObjectPath = (pathname: string) => { + const prefix = `/${LEGACY_BUCKET_NAME}/`; + if (pathname.startsWith(prefix)) { + return pathname.slice(prefix.length - 1); + } + + if (pathname === `/${LEGACY_BUCKET_NAME}`) { + return "/"; + } + + return null; +}; + +const isHttpUrl = (value: string) => value.startsWith("http://") || value.startsWith("https://"); + +export const normalizeImageUrlToUploadCdn = (url: string | null | undefined): string => { + if (!url) return ""; + + const trimmed = url.trim(); + if (!trimmed) return ""; + + if (trimmed.startsWith("blob:") || trimmed.startsWith("data:") || trimmed.startsWith("/")) { + return trimmed; + } + + if (trimmed.startsWith("//")) { + return normalizeImageUrlToUploadCdn(`https:${trimmed}`); + } + + if (!isHttpUrl(trimmed)) { + return joinUploadOrigin(trimmed.replace(/^\/+/, "")); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return trimmed; + } + + const hostname = parsed.hostname.toLowerCase(); + + if (cdnHostnames.has(hostname)) { + return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash); + } + + if (legacyS3VirtualHostRegex.test(hostname)) { + return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash); + } + + if (s3PathStyleHostRegex.test(hostname)) { + const objectPath = getLegacyS3ObjectPath(parsed.pathname); + if (objectPath) { + return joinUploadOrigin(objectPath, parsed.search, parsed.hash); + } + } + + return trimmed; +}; diff --git a/apps/web/src/components/ui/FallbackImage.tsx b/apps/web/src/components/ui/FallbackImage.tsx index 552c2f36..14d242f4 100644 --- a/apps/web/src/components/ui/FallbackImage.tsx +++ b/apps/web/src/components/ui/FallbackImage.tsx @@ -2,31 +2,33 @@ import NextImage from "next/image"; import { useState } from "react"; +import { getUploadCdnOrigin, normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; const DEFAULT_FALLBACK_SRC = "/svgs/placeholders/image-placeholder.svg"; -const DEFAULT_CDN_HOST = "https://cdn.default.solid-connection.com"; -const UPLOAD_CDN_HOST = "https://cdn.upload.solid-connection.com"; type CdnHostType = "default" | "upload"; const CDN_HOSTS: Record = { - default: process.env.NEXT_PUBLIC_IMAGE_URL || DEFAULT_CDN_HOST, - upload: process.env.NEXT_PUBLIC_UPLOADED_IMAGE_URL || UPLOAD_CDN_HOST, + default: getUploadCdnOrigin(), + upload: getUploadCdnOrigin(), }; const resolveCdnUrl = (src: string, cdnHostType?: CdnHostType) => { const trimmedSrc = src.trim(); if (trimmedSrc.length === 0) return ""; - if (trimmedSrc.startsWith("http://") || trimmedSrc.startsWith("https://")) return trimmedSrc; + if (trimmedSrc.startsWith("http://") || trimmedSrc.startsWith("https://")) { + return normalizeImageUrlToUploadCdn(trimmedSrc); + } if (trimmedSrc.startsWith("blob:") || trimmedSrc.startsWith("data:")) return trimmedSrc; - if (trimmedSrc.startsWith("//")) return `https:${trimmedSrc}`; - if (!cdnHostType) return trimmedSrc; + if (trimmedSrc.startsWith("//")) return normalizeImageUrlToUploadCdn(`https:${trimmedSrc}`); + if (trimmedSrc.startsWith("/")) return trimmedSrc; + if (!cdnHostType) return normalizeImageUrlToUploadCdn(trimmedSrc); const normalizedHost = CDN_HOSTS[cdnHostType].replace(/\/+$/, ""); const normalizedPath = trimmedSrc.replace(/^\/+/, ""); - return `${normalizedHost}/${normalizedPath}`; + return normalizeImageUrlToUploadCdn(`${normalizedHost}/${normalizedPath}`); }; type FallbackImageProps = React.ComponentProps & { diff --git a/apps/web/src/utils/cdnUrl.ts b/apps/web/src/utils/cdnUrl.ts new file mode 100644 index 00000000..17661ec3 --- /dev/null +++ b/apps/web/src/utils/cdnUrl.ts @@ -0,0 +1,132 @@ +const UPLOAD_CDN_ORIGIN = "https://cdn.upload.solid-connection.com"; +const UPLOAD_CDN_HOSTNAME = "cdn.upload.solid-connection.com"; +const DEFAULT_CDN_HOSTNAME = "cdn.default.solid-connection.com"; +const LEGACY_BUCKET_NAME = "solid-connection"; + +const LOCAL_STATIC_PREFIXES = ["/images/", "/svgs/"]; + +const isHttpUrl = (value: string) => value.startsWith("http://") || value.startsWith("https://"); + +const normalizeOrigin = (value: string | undefined) => { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + + try { + const parsed = new URL(trimmed); + return `${parsed.protocol}//${parsed.host}`.replace(/\/+$/, ""); + } catch { + return undefined; + } +}; + +const getHostname = (value: string | undefined) => { + if (!value) return undefined; + + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return undefined; + } +}; + +const envUploadOrigin = normalizeOrigin(process.env.NEXT_PUBLIC_UPLOADED_IMAGE_URL); +const envDefaultOrigin = normalizeOrigin(process.env.NEXT_PUBLIC_IMAGE_URL); + +const uploadOrigin = envUploadOrigin ?? UPLOAD_CDN_ORIGIN; + +const cdnHostnames = new Set( + [UPLOAD_CDN_HOSTNAME, DEFAULT_CDN_HOSTNAME, getHostname(envUploadOrigin), getHostname(envDefaultOrigin)].filter( + (hostname): hostname is string => Boolean(hostname), + ), +); + +const legacyS3VirtualHostRegex = new RegExp( + `^${LEGACY_BUCKET_NAME.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\.s3([.-][a-z0-9-]+)?\\.amazonaws\\.com$`, + "i", +); + +const s3PathStyleHostRegex = /^s3([.-][a-z0-9-]+)?\.amazonaws\.com$/i; + +const joinUploadOrigin = (path: string, search = "", hash = "") => { + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${uploadOrigin}${normalizedPath}${search}${hash}`; +}; + +const getLegacyS3ObjectPath = (pathname: string) => { + const prefix = `/${LEGACY_BUCKET_NAME}/`; + if (pathname.startsWith(prefix)) { + return pathname.slice(prefix.length - 1); + } + + if (pathname === `/${LEGACY_BUCKET_NAME}`) { + return "/"; + } + + return null; +}; + +const shouldKeepAsLocalStaticPath = (value: string) => { + return LOCAL_STATIC_PREFIXES.some((prefix) => value.startsWith(prefix)); +}; + +/** + * 이미지 URL을 upload CDN 기준으로 정규화한다. + * - 상대 경로(key)는 upload CDN으로 변환 + * - legacy default CDN/S3 URL은 upload CDN으로 변환 + * - 로컬 정적 경로(/images, /svgs), blob/data, 외부 도메인은 유지 + */ +export const normalizeImageUrlToUploadCdn = (url: string | null | undefined): string => { + if (!url) return ""; + + const trimmed = url.trim(); + if (!trimmed) return ""; + + if (trimmed.startsWith("blob:") || trimmed.startsWith("data:")) { + return trimmed; + } + + if (trimmed.startsWith("//")) { + return normalizeImageUrlToUploadCdn(`https:${trimmed}`); + } + + if (trimmed.startsWith("/")) { + if (shouldKeepAsLocalStaticPath(trimmed)) { + return trimmed; + } + + return trimmed; + } + + if (!isHttpUrl(trimmed)) { + return joinUploadOrigin(trimmed.replace(/^\/+/, "")); + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return trimmed; + } + + const hostname = parsed.hostname.toLowerCase(); + + if (cdnHostnames.has(hostname)) { + return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash); + } + + if (legacyS3VirtualHostRegex.test(hostname)) { + return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash); + } + + if (s3PathStyleHostRegex.test(hostname)) { + const objectPath = getLegacyS3ObjectPath(parsed.pathname); + if (objectPath) { + return joinUploadOrigin(objectPath, parsed.search, parsed.hash); + } + } + + return trimmed; +}; + +export const getUploadCdnOrigin = () => uploadOrigin; diff --git a/apps/web/src/utils/fileUtils.ts b/apps/web/src/utils/fileUtils.ts index 4acf897f..0b093ab5 100644 --- a/apps/web/src/utils/fileUtils.ts +++ b/apps/web/src/utils/fileUtils.ts @@ -1,3 +1,5 @@ +import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; + // 파일명에서 확장자 추출 export const getFileExtension = (url: string) => { return url.split(".").pop()?.toUpperCase() || "FILE"; @@ -57,24 +59,10 @@ export const downloadLocalFile = (file: File, fileName?: string) => { URL.revokeObjectURL(blobUrl); }; -const NEXT_PUBLIC_UPLOADED_IMAGE_URL = process.env.NEXT_PUBLIC_UPLOADED_IMAGE_URL; -const NEXT_PUBLIC_IMAGE_URL = process.env.NEXT_PUBLIC_IMAGE_URL; - export const convertUploadedImageUrl = (url: string | null | undefined): string => { - if (!url) return ""; - if (url.startsWith("http") || url.startsWith("blob")) return url; - if (!NEXT_PUBLIC_UPLOADED_IMAGE_URL) { - return url; - } - return `${NEXT_PUBLIC_UPLOADED_IMAGE_URL}/${url}`; + return normalizeImageUrlToUploadCdn(url); }; export const convertImageUrl = (url: string | null | undefined): string => { - if (!url) return ""; - if (url.startsWith("https://img.example")) return `${NEXT_PUBLIC_IMAGE_URL}/${url}`; - if (url.startsWith("http") || url.startsWith("blob")) return url; - if (!NEXT_PUBLIC_IMAGE_URL) { - return url; - } - return `${NEXT_PUBLIC_IMAGE_URL}/${url}`; + return normalizeImageUrlToUploadCdn(url); };