Skip to content

Commit ea99d6d

Browse files
committed
fix(web,admin): unify image urls to upload cdn
1 parent 936cd22 commit ea99d6d

6 files changed

Lines changed: 263 additions & 32 deletions

File tree

apps/admin/src/components/features/scores/GpaScoreTable.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { toast } from "sonner";
55
import { Button } from "@/components/ui/button";
66
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
77
import { scoreApi } from "@/lib/api/scores";
8+
import { normalizeImageUrlToUploadCdn } from "@/lib/utils/cdnUrl";
89
import type { GpaScoreWithUser, VerifyStatus } from "@/types/scores";
910
import { ScoreVerifyButton } from "./ScoreVerifyButton";
1011
import { StatusBadge } from "./StatusBadge";
@@ -13,8 +14,6 @@ interface Props {
1314
verifyFilter: VerifyStatus;
1415
}
1516

16-
const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || "";
17-
1817
export function GpaScoreTable({ verifyFilter }: Props) {
1918
const queryClient = useQueryClient();
2019
const [page, setPage] = useState(1);
@@ -142,7 +141,7 @@ export function GpaScoreTable({ verifyFilter }: Props) {
142141
<TableCell>
143142
<div className="flex items-center">
144143
<img
145-
src={score.siteUserResponse.profileImageUrl}
144+
src={normalizeImageUrlToUploadCdn(score.siteUserResponse.profileImageUrl)}
146145
alt="프로필"
147146
className="mr-2 h-8 w-8 rounded-full border border-k-100"
148147
/>
@@ -197,7 +196,7 @@ export function GpaScoreTable({ verifyFilter }: Props) {
197196
<TableCell>{score.gpaScoreStatusResponse.rejectedReason || "-"}</TableCell>
198197
<TableCell>
199198
<a
200-
href={`${S3_BASE_URL}${score.gpaScoreStatusResponse.gpaResponse.gpaReportUrl}`}
199+
href={normalizeImageUrlToUploadCdn(score.gpaScoreStatusResponse.gpaResponse.gpaReportUrl)}
201200
target="_blank"
202201
rel="noopener noreferrer"
203202
className="typo-medium-4 text-primary hover:text-primary-700 hover:underline"

apps/admin/src/components/features/scores/LanguageScoreTable.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { toast } from "sonner";
55
import { Button } from "@/components/ui/button";
66
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
77
import { scoreApi } from "@/lib/api/scores";
8+
import { normalizeImageUrlToUploadCdn } from "@/lib/utils/cdnUrl";
89
import type { LanguageScoreWithUser, LanguageTestType, VerifyStatus } from "@/types/scores";
910
import { ScoreVerifyButton } from "./ScoreVerifyButton";
1011
import { StatusBadge } from "./StatusBadge";
@@ -13,8 +14,6 @@ interface Props {
1314
verifyFilter: VerifyStatus;
1415
}
1516

16-
const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || "";
17-
1817
const LANGUAGE_TEST_OPTIONS: { value: LanguageTestType; label: string }[] = [
1918
{ value: "TOEIC", label: "TOEIC" },
2019
{ value: "TOEFL_IBT", label: "TOEFL IBT" },
@@ -157,7 +156,7 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
157156
<TableCell>
158157
<div className="flex items-center">
159158
<img
160-
src={score.siteUserResponse.profileImageUrl}
159+
src={normalizeImageUrlToUploadCdn(score.siteUserResponse.profileImageUrl)}
161160
alt="프로필"
162161
className="mr-2 h-8 w-8 rounded-full border border-k-100"
163162
/>
@@ -220,7 +219,9 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
220219
<TableCell>{score.languageTestScoreStatusResponse.rejectedReason || "-"}</TableCell>
221220
<TableCell>
222221
<a
223-
href={`${S3_BASE_URL}${score.languageTestScoreStatusResponse.languageTestResponse.languageTestReportUrl}`}
222+
href={normalizeImageUrlToUploadCdn(
223+
score.languageTestScoreStatusResponse.languageTestResponse.languageTestReportUrl,
224+
)}
224225
target="_blank"
225226
rel="noopener noreferrer"
226227
className="typo-medium-4 text-primary hover:text-primary-700 hover:underline"

apps/admin/src/lib/utils/cdnUrl.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
const UPLOAD_CDN_ORIGIN = "https://cdn.upload.solid-connection.com";
2+
const UPLOAD_CDN_HOSTNAME = "cdn.upload.solid-connection.com";
3+
const DEFAULT_CDN_HOSTNAME = "cdn.default.solid-connection.com";
4+
const LEGACY_BUCKET_NAME = "solid-connection";
5+
6+
const s3PathStyleHostRegex = /^s3([.-][a-z0-9-]+)?\.amazonaws\.com$/i;
7+
const legacyS3VirtualHostRegex = new RegExp(
8+
`^${LEGACY_BUCKET_NAME.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\.s3([.-][a-z0-9-]+)?\\.amazonaws\\.com$`,
9+
"i",
10+
);
11+
12+
const normalizeOrigin = (value: string | undefined) => {
13+
if (!value) return undefined;
14+
const trimmed = value.trim();
15+
if (!trimmed) return undefined;
16+
17+
try {
18+
const parsed = new URL(trimmed);
19+
return `${parsed.protocol}//${parsed.host}`.replace(/\/+$/, "");
20+
} catch {
21+
return undefined;
22+
}
23+
};
24+
25+
const getHostname = (value: string | undefined) => {
26+
if (!value) return undefined;
27+
28+
try {
29+
return new URL(value).hostname.toLowerCase();
30+
} catch {
31+
return undefined;
32+
}
33+
};
34+
35+
const runtimeEnv = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env;
36+
const envUploadOrigin = normalizeOrigin(runtimeEnv?.VITE_UPLOADED_IMAGE_URL);
37+
const envS3BaseOrigin = normalizeOrigin(runtimeEnv?.VITE_S3_BASE_URL);
38+
const uploadOrigin = envUploadOrigin ?? UPLOAD_CDN_ORIGIN;
39+
40+
const cdnHostnames = new Set(
41+
[UPLOAD_CDN_HOSTNAME, DEFAULT_CDN_HOSTNAME, getHostname(envUploadOrigin), getHostname(envS3BaseOrigin)].filter(
42+
(hostname): hostname is string => Boolean(hostname),
43+
),
44+
);
45+
46+
const joinUploadOrigin = (path: string, search = "", hash = "") => {
47+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
48+
return `${uploadOrigin}${normalizedPath}${search}${hash}`;
49+
};
50+
51+
const getLegacyS3ObjectPath = (pathname: string) => {
52+
const prefix = `/${LEGACY_BUCKET_NAME}/`;
53+
if (pathname.startsWith(prefix)) {
54+
return pathname.slice(prefix.length - 1);
55+
}
56+
57+
if (pathname === `/${LEGACY_BUCKET_NAME}`) {
58+
return "/";
59+
}
60+
61+
return null;
62+
};
63+
64+
const isHttpUrl = (value: string) => value.startsWith("http://") || value.startsWith("https://");
65+
66+
export const normalizeImageUrlToUploadCdn = (url: string | null | undefined): string => {
67+
if (!url) return "";
68+
69+
const trimmed = url.trim();
70+
if (!trimmed) return "";
71+
72+
if (trimmed.startsWith("blob:") || trimmed.startsWith("data:") || trimmed.startsWith("/")) {
73+
return trimmed;
74+
}
75+
76+
if (trimmed.startsWith("//")) {
77+
return normalizeImageUrlToUploadCdn(`https:${trimmed}`);
78+
}
79+
80+
if (!isHttpUrl(trimmed)) {
81+
return joinUploadOrigin(trimmed.replace(/^\/+/, ""));
82+
}
83+
84+
let parsed: URL;
85+
try {
86+
parsed = new URL(trimmed);
87+
} catch {
88+
return trimmed;
89+
}
90+
91+
const hostname = parsed.hostname.toLowerCase();
92+
93+
if (cdnHostnames.has(hostname)) {
94+
return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash);
95+
}
96+
97+
if (legacyS3VirtualHostRegex.test(hostname)) {
98+
return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash);
99+
}
100+
101+
if (s3PathStyleHostRegex.test(hostname)) {
102+
const objectPath = getLegacyS3ObjectPath(parsed.pathname);
103+
if (objectPath) {
104+
return joinUploadOrigin(objectPath, parsed.search, parsed.hash);
105+
}
106+
}
107+
108+
return trimmed;
109+
};

apps/web/src/components/ui/FallbackImage.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,33 @@
22

33
import NextImage from "next/image";
44
import { useState } from "react";
5+
import { getUploadCdnOrigin, normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl";
56

67
const DEFAULT_FALLBACK_SRC = "/svgs/placeholders/image-placeholder.svg";
7-
const DEFAULT_CDN_HOST = "https://cdn.default.solid-connection.com";
8-
const UPLOAD_CDN_HOST = "https://cdn.upload.solid-connection.com";
98

109
type CdnHostType = "default" | "upload";
1110

1211
const CDN_HOSTS: Record<CdnHostType, string> = {
13-
default: process.env.NEXT_PUBLIC_IMAGE_URL || DEFAULT_CDN_HOST,
14-
upload: process.env.NEXT_PUBLIC_UPLOADED_IMAGE_URL || UPLOAD_CDN_HOST,
12+
default: getUploadCdnOrigin(),
13+
upload: getUploadCdnOrigin(),
1514
};
1615

1716
const resolveCdnUrl = (src: string, cdnHostType?: CdnHostType) => {
1817
const trimmedSrc = src.trim();
1918

2019
if (trimmedSrc.length === 0) return "";
21-
if (trimmedSrc.startsWith("http://") || trimmedSrc.startsWith("https://")) return trimmedSrc;
20+
if (trimmedSrc.startsWith("http://") || trimmedSrc.startsWith("https://")) {
21+
return normalizeImageUrlToUploadCdn(trimmedSrc);
22+
}
2223
if (trimmedSrc.startsWith("blob:") || trimmedSrc.startsWith("data:")) return trimmedSrc;
23-
if (trimmedSrc.startsWith("//")) return `https:${trimmedSrc}`;
24-
if (!cdnHostType) return trimmedSrc;
24+
if (trimmedSrc.startsWith("//")) return normalizeImageUrlToUploadCdn(`https:${trimmedSrc}`);
25+
if (trimmedSrc.startsWith("/")) return trimmedSrc;
26+
if (!cdnHostType) return normalizeImageUrlToUploadCdn(trimmedSrc);
2527

2628
const normalizedHost = CDN_HOSTS[cdnHostType].replace(/\/+$/, "");
2729
const normalizedPath = trimmedSrc.replace(/^\/+/, "");
2830

29-
return `${normalizedHost}/${normalizedPath}`;
31+
return normalizeImageUrlToUploadCdn(`${normalizedHost}/${normalizedPath}`);
3032
};
3133

3234
type FallbackImageProps = React.ComponentProps<typeof NextImage> & {

apps/web/src/utils/cdnUrl.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const UPLOAD_CDN_ORIGIN = "https://cdn.upload.solid-connection.com";
2+
const UPLOAD_CDN_HOSTNAME = "cdn.upload.solid-connection.com";
3+
const DEFAULT_CDN_HOSTNAME = "cdn.default.solid-connection.com";
4+
const LEGACY_BUCKET_NAME = "solid-connection";
5+
6+
const LOCAL_STATIC_PREFIXES = ["/images/", "/svgs/"];
7+
8+
const isHttpUrl = (value: string) => value.startsWith("http://") || value.startsWith("https://");
9+
10+
const normalizeOrigin = (value: string | undefined) => {
11+
if (!value) return undefined;
12+
const trimmed = value.trim();
13+
if (!trimmed) return undefined;
14+
15+
try {
16+
const parsed = new URL(trimmed);
17+
return `${parsed.protocol}//${parsed.host}`.replace(/\/+$/, "");
18+
} catch {
19+
return undefined;
20+
}
21+
};
22+
23+
const getHostname = (value: string | undefined) => {
24+
if (!value) return undefined;
25+
26+
try {
27+
return new URL(value).hostname.toLowerCase();
28+
} catch {
29+
return undefined;
30+
}
31+
};
32+
33+
const envUploadOrigin = normalizeOrigin(process.env.NEXT_PUBLIC_UPLOADED_IMAGE_URL);
34+
const envDefaultOrigin = normalizeOrigin(process.env.NEXT_PUBLIC_IMAGE_URL);
35+
36+
const uploadOrigin = envUploadOrigin ?? UPLOAD_CDN_ORIGIN;
37+
38+
const cdnHostnames = new Set(
39+
[UPLOAD_CDN_HOSTNAME, DEFAULT_CDN_HOSTNAME, getHostname(envUploadOrigin), getHostname(envDefaultOrigin)].filter(
40+
(hostname): hostname is string => Boolean(hostname),
41+
),
42+
);
43+
44+
const legacyS3VirtualHostRegex = new RegExp(
45+
`^${LEGACY_BUCKET_NAME.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\\.s3([.-][a-z0-9-]+)?\\.amazonaws\\.com$`,
46+
"i",
47+
);
48+
49+
const s3PathStyleHostRegex = /^s3([.-][a-z0-9-]+)?\.amazonaws\.com$/i;
50+
51+
const joinUploadOrigin = (path: string, search = "", hash = "") => {
52+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
53+
return `${uploadOrigin}${normalizedPath}${search}${hash}`;
54+
};
55+
56+
const getLegacyS3ObjectPath = (pathname: string) => {
57+
const prefix = `/${LEGACY_BUCKET_NAME}/`;
58+
if (pathname.startsWith(prefix)) {
59+
return pathname.slice(prefix.length - 1);
60+
}
61+
62+
if (pathname === `/${LEGACY_BUCKET_NAME}`) {
63+
return "/";
64+
}
65+
66+
return null;
67+
};
68+
69+
const shouldKeepAsLocalStaticPath = (value: string) => {
70+
return LOCAL_STATIC_PREFIXES.some((prefix) => value.startsWith(prefix));
71+
};
72+
73+
/**
74+
* 이미지 URL을 upload CDN 기준으로 정규화한다.
75+
* - 상대 경로(key)는 upload CDN으로 변환
76+
* - legacy default CDN/S3 URL은 upload CDN으로 변환
77+
* - 로컬 정적 경로(/images, /svgs), blob/data, 외부 도메인은 유지
78+
*/
79+
export const normalizeImageUrlToUploadCdn = (url: string | null | undefined): string => {
80+
if (!url) return "";
81+
82+
const trimmed = url.trim();
83+
if (!trimmed) return "";
84+
85+
if (trimmed.startsWith("blob:") || trimmed.startsWith("data:")) {
86+
return trimmed;
87+
}
88+
89+
if (trimmed.startsWith("//")) {
90+
return normalizeImageUrlToUploadCdn(`https:${trimmed}`);
91+
}
92+
93+
if (trimmed.startsWith("/")) {
94+
if (shouldKeepAsLocalStaticPath(trimmed)) {
95+
return trimmed;
96+
}
97+
98+
return trimmed;
99+
}
100+
101+
if (!isHttpUrl(trimmed)) {
102+
return joinUploadOrigin(trimmed.replace(/^\/+/, ""));
103+
}
104+
105+
let parsed: URL;
106+
try {
107+
parsed = new URL(trimmed);
108+
} catch {
109+
return trimmed;
110+
}
111+
112+
const hostname = parsed.hostname.toLowerCase();
113+
114+
if (cdnHostnames.has(hostname)) {
115+
return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash);
116+
}
117+
118+
if (legacyS3VirtualHostRegex.test(hostname)) {
119+
return joinUploadOrigin(parsed.pathname, parsed.search, parsed.hash);
120+
}
121+
122+
if (s3PathStyleHostRegex.test(hostname)) {
123+
const objectPath = getLegacyS3ObjectPath(parsed.pathname);
124+
if (objectPath) {
125+
return joinUploadOrigin(objectPath, parsed.search, parsed.hash);
126+
}
127+
}
128+
129+
return trimmed;
130+
};
131+
132+
export const getUploadCdnOrigin = () => uploadOrigin;

apps/web/src/utils/fileUtils.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl";
2+
13
// 파일명에서 확장자 추출
24
export const getFileExtension = (url: string) => {
35
return url.split(".").pop()?.toUpperCase() || "FILE";
@@ -57,24 +59,10 @@ export const downloadLocalFile = (file: File, fileName?: string) => {
5759
URL.revokeObjectURL(blobUrl);
5860
};
5961

60-
const NEXT_PUBLIC_UPLOADED_IMAGE_URL = process.env.NEXT_PUBLIC_UPLOADED_IMAGE_URL;
61-
const NEXT_PUBLIC_IMAGE_URL = process.env.NEXT_PUBLIC_IMAGE_URL;
62-
6362
export const convertUploadedImageUrl = (url: string | null | undefined): string => {
64-
if (!url) return "";
65-
if (url.startsWith("http") || url.startsWith("blob")) return url;
66-
if (!NEXT_PUBLIC_UPLOADED_IMAGE_URL) {
67-
return url;
68-
}
69-
return `${NEXT_PUBLIC_UPLOADED_IMAGE_URL}/${url}`;
63+
return normalizeImageUrlToUploadCdn(url);
7064
};
7165

7266
export const convertImageUrl = (url: string | null | undefined): string => {
73-
if (!url) return "";
74-
if (url.startsWith("https://img.example")) return `${NEXT_PUBLIC_IMAGE_URL}/${url}`;
75-
if (url.startsWith("http") || url.startsWith("blob")) return url;
76-
if (!NEXT_PUBLIC_IMAGE_URL) {
77-
return url;
78-
}
79-
return `${NEXT_PUBLIC_IMAGE_URL}/${url}`;
67+
return normalizeImageUrlToUploadCdn(url);
8068
};

0 commit comments

Comments
 (0)