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
7 changes: 3 additions & 4 deletions apps/admin/src/components/features/scores/GpaScoreTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -142,7 +141,7 @@ export function GpaScoreTable({ verifyFilter }: Props) {
<TableCell>
<div className="flex items-center">
<img
src={score.siteUserResponse.profileImageUrl}
src={normalizeImageUrlToUploadCdn(score.siteUserResponse.profileImageUrl)}
alt="프로필"
className="mr-2 h-8 w-8 rounded-full border border-k-100"
/>
Expand Down Expand Up @@ -197,7 +196,7 @@ export function GpaScoreTable({ verifyFilter }: Props) {
<TableCell>{score.gpaScoreStatusResponse.rejectedReason || "-"}</TableCell>
<TableCell>
<a
href={`${S3_BASE_URL}${score.gpaScoreStatusResponse.gpaResponse.gpaReportUrl}`}
href={normalizeImageUrlToUploadCdn(score.gpaScoreStatusResponse.gpaResponse.gpaReportUrl)}
target="_blank"
rel="noopener noreferrer"
className="typo-medium-4 text-primary hover:text-primary-700 hover:underline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { LanguageScoreWithUser, LanguageTestType, VerifyStatus } from "@/types/scores";
import { ScoreVerifyButton } from "./ScoreVerifyButton";
import { StatusBadge } from "./StatusBadge";
Expand All @@ -13,8 +14,6 @@ interface Props {
verifyFilter: VerifyStatus;
}

const S3_BASE_URL = (import.meta.env.VITE_S3_BASE_URL as string | undefined) || "";

const LANGUAGE_TEST_OPTIONS: { value: LanguageTestType; label: string }[] = [
{ value: "TOEIC", label: "TOEIC" },
{ value: "TOEFL_IBT", label: "TOEFL IBT" },
Expand Down Expand Up @@ -157,7 +156,7 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
<TableCell>
<div className="flex items-center">
<img
src={score.siteUserResponse.profileImageUrl}
src={normalizeImageUrlToUploadCdn(score.siteUserResponse.profileImageUrl)}
alt="프로필"
className="mr-2 h-8 w-8 rounded-full border border-k-100"
/>
Expand Down Expand Up @@ -220,7 +219,9 @@ export function LanguageScoreTable({ verifyFilter }: Props) {
<TableCell>{score.languageTestScoreStatusResponse.rejectedReason || "-"}</TableCell>
<TableCell>
<a
href={`${S3_BASE_URL}${score.languageTestScoreStatusResponse.languageTestResponse.languageTestReportUrl}`}
href={normalizeImageUrlToUploadCdn(
score.languageTestScoreStatusResponse.languageTestResponse.languageTestReportUrl,
)}
target="_blank"
rel="noopener noreferrer"
className="typo-medium-4 text-primary hover:text-primary-700 hover:underline"
Expand Down
109 changes: 109 additions & 0 deletions apps/admin/src/lib/utils/cdnUrl.ts
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;
Comment on lines +72 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Convert slash-prefixed report paths to upload CDN

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 with VITE_S3_BASE_URL, so this change can break report downloads for relative-path data.

Useful? React with 👍 / 👎.

}

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;
};
18 changes: 10 additions & 8 deletions apps/web/src/components/ui/FallbackImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve CDN host mapping for slash-prefixed image keys

resolveCdnUrl now returns any "/..." source immediately, which bypasses cdnHostType host resolution. For call sites like ProfileWithBadge that pass cdnHostType="upload", slash-prefixed API keys (for example /profile/abc.png) are now requested from the web origin instead of the CDN and will 404/fall back. This is a behavioral regression from the previous implementation, which normalized both prefixed and unprefixed relative keys onto the CDN host.

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> & {
Expand Down
132 changes: 132 additions & 0 deletions apps/web/src/utils/cdnUrl.ts
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;
20 changes: 4 additions & 16 deletions apps/web/src/utils/fileUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl";

// 파일명에서 확장자 추출
export const getFileExtension = (url: string) => {
return url.split(".").pop()?.toUpperCase() || "FILE";
Expand Down Expand Up @@ -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);
};
Loading