-
Notifications
You must be signed in to change notification settings - Fork 3
fix(web,admin): 이미지 URL을 upload CDN으로 통일 #510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| 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 s3PathStyleHostRegex = /^s3([.-][a-z0-9-]+)?\.amazonaws\.com$/i; | ||
| const legacyS3VirtualHostRegex = new RegExp( | ||
| `^${LEGACY_BUCKET_NAME.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\.s3([.-][a-z0-9-]+)?\\.amazonaws\\.com$`, | ||
| "i", | ||
| ); | ||
|
|
||
| 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 runtimeEnv = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).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; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<CdnHostType, string> = { | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| 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<typeof NextImage> & { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The admin URL normalizer treats any value starting with
/as already-resolved and returns it unchanged. After this commit, GPA/language report links use this helper, so slash-prefixed report keys (for example/scores/report.pdf) now open against the admin app origin instead of the CDN object URL. Previously these links were explicitly prefixed withVITE_S3_BASE_URL, so this change can break report downloads for relative-path data.Useful? React with 👍 / 👎.