UNPKG

@restnfeel/agentc-starter-kit

Version:

한국어 기업용 CMS 모듈 - Task Master AI와 함께 빠르게 웹사이트를 구현할 수 있는 재사용 가능한 컴포넌트 시스템

377 lines (317 loc) 10 kB
import { NextRequest, NextResponse } from "next/server"; import { getSiteByDomain, getSiteBySlug } from "../services/site-api"; // 테넌트 컨텍스트 인터페이스 export interface TenantContext { siteId: string; siteName: string; siteSlug: string; domain?: string | null; subdomain?: string | null; isPublished: boolean; settings?: Record<string, unknown> | null; } // 멀티테넌트 라우팅 타입 type RoutingStrategy = "subdomain" | "domain" | "path"; // 멀티테넌트 설정 interface MultiTenantConfig { strategy: RoutingStrategy; mainDomain: string; adminPath: string; apiPath: string; publicPath: string; excludePaths: string[]; } // 기본 설정 const defaultConfig: MultiTenantConfig = { strategy: "subdomain", mainDomain: process.env.NEXT_PUBLIC_APP_DOMAIN || "localhost:3000", adminPath: "/admin", apiPath: "/api", publicPath: "/public", excludePaths: [ "/_next", "/favicon.ico", "/robots.txt", "/sitemap.xml", "/api", "/admin", "/public", "/login", "/register", "/reset-password", "/verify-email", ], }; export class MultiTenantManager { private config: MultiTenantConfig; constructor(config: Partial<MultiTenantConfig> = {}) { this.config = { ...defaultConfig, ...config }; } // 테넌트 컨텍스트 추출 async extractTenantContext( request: NextRequest ): Promise<TenantContext | null> { const { strategy } = this.config; const url = new URL(request.url); try { switch (strategy) { case "subdomain": return await this.extractFromSubdomain(url); case "domain": return await this.extractFromDomain(url); case "path": return await this.extractFromPath(url); default: return null; } } catch (error) { console.error("테넌트 컨텍스트 추출 실패:", error); return null; } } // 서브도메인에서 테넌트 추출 private async extractFromSubdomain(url: URL): Promise<TenantContext | null> { const hostname = url.hostname; const parts = hostname.split("."); // 최소 3개 부분이 있어야 서브도메인 (subdomain.domain.tld) if (parts.length < 3) { return null; } const subdomain = parts[0]; // 예약된 서브도메인 제외 const reservedSubdomains = ["www", "admin", "api", "app", "mail", "ftp"]; if (reservedSubdomains.includes(subdomain)) { return null; } // 서브도메인으로 사이트 조회 const site = await getSiteByDomain(subdomain); if (!site) { return null; } return { siteId: site.id, siteName: site.name, siteSlug: site.slug, domain: site.domain, subdomain: site.subdomain, isPublished: site.isPublished, settings: site.settings, }; } // 커스텀 도메인에서 테넌트 추출 private async extractFromDomain(url: URL): Promise<TenantContext | null> { const hostname = url.hostname; // 메인 도메인이면 null 반환 if (hostname === this.config.mainDomain) { return null; } // 커스텀 도메인으로 사이트 조회 const site = await getSiteByDomain(hostname); if (!site) { return null; } return { siteId: site.id, siteName: site.name, siteSlug: site.slug, domain: site.domain, subdomain: site.subdomain, isPublished: site.isPublished, settings: site.settings, }; } // 경로에서 테넌트 추출 private async extractFromPath(url: URL): Promise<TenantContext | null> { const pathSegments = url.pathname.split("/").filter(Boolean); if (pathSegments.length === 0) { return null; } const siteSlug = pathSegments[0]; // 예약된 경로 제외 if ( this.config.excludePaths.some((path) => url.pathname.startsWith(path)) ) { return null; } // 슬러그로 사이트 조회 const site = await getSiteBySlug(siteSlug); if (!site) { return null; } return { siteId: site.id, siteName: site.name, siteSlug: site.slug, domain: site.domain, subdomain: site.subdomain, isPublished: site.isPublished, settings: site.settings, }; } // 요청이 테넌트 요청인지 확인 shouldProcessAsTenant(request: NextRequest): boolean { const url = new URL(request.url); // 제외 경로 확인 for (const excludePath of this.config.excludePaths) { if (url.pathname.startsWith(excludePath)) { return false; } } // 관리자 경로 확인 if (url.pathname.startsWith(this.config.adminPath)) { return false; } // API 경로 확인 if (url.pathname.startsWith(this.config.apiPath)) { return false; } return true; } // 테넌트 라우팅 처리 async handleTenantRouting( request: NextRequest, tenantContext: TenantContext ): Promise<NextResponse> { const url = new URL(request.url); // 사이트가 발행되지 않은 경우 if (!tenantContext.isPublished) { // 개발 모드나 소유자 액세스 확인 로직 추가 가능 return new NextResponse("사이트를 찾을 수 없습니다", { status: 404 }); } // 테넌트 컨텍스트를 헤더에 추가 const headers = new Headers(request.headers); headers.set("x-tenant-id", tenantContext.siteId); headers.set("x-tenant-slug", tenantContext.siteSlug); headers.set("x-tenant-name", tenantContext.siteName); if (tenantContext.domain) { headers.set("x-tenant-domain", tenantContext.domain); } if (tenantContext.subdomain) { headers.set("x-tenant-subdomain", tenantContext.subdomain); } // 테넌트별 라우팅 전략에 따른 URL 재작성 let rewriteUrl: string; switch (this.config.strategy) { case "subdomain": case "domain": // 서브도메인/도메인 기반: 경로를 그대로 유지하고 테넌트 컨텍스트만 추가 rewriteUrl = url.pathname + url.search; break; case "path": // 경로 기반: 첫 번째 세그먼트(사이트 슬러그) 제거 const pathSegments = url.pathname.split("/").filter(Boolean); const newPath = pathSegments.length > 1 ? "/" + pathSegments.slice(1).join("/") : "/"; rewriteUrl = newPath + url.search; break; default: rewriteUrl = url.pathname + url.search; } // 테넌트 전용 페이지로 라우팅 const tenantPageUrl = new URL(`/tenant${rewriteUrl}`, request.url); return NextResponse.rewrite(tenantPageUrl, { headers, }); } // 관리자 대시보드 액세스 확인 isAdminAccess(request: NextRequest): boolean { const url = new URL(request.url); return url.pathname.startsWith(this.config.adminPath); } // API 요청인지 확인 isApiRequest(request: NextRequest): boolean { const url = new URL(request.url); return url.pathname.startsWith(this.config.apiPath); } // 정적 파일 요청인지 확인 isStaticRequest(request: NextRequest): boolean { const url = new URL(request.url); const staticExtensions = [ ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ]; return ( staticExtensions.some((ext) => url.pathname.endsWith(ext)) || url.pathname.startsWith("/_next/") || url.pathname.startsWith("/public/") ); } } // 전역 멀티테넌트 매니저 인스턴스 export const multiTenantManager = new MultiTenantManager(); // 미들웨어 함수 export async function multiTenantMiddleware( request: NextRequest ): Promise<NextResponse> { try { // 정적 파일이나 API 요청은 그대로 통과 if ( multiTenantManager.isStaticRequest(request) || multiTenantManager.isApiRequest(request) || multiTenantManager.isAdminAccess(request) ) { return NextResponse.next(); } // 테넌트 처리가 필요한지 확인 if (!multiTenantManager.shouldProcessAsTenant(request)) { return NextResponse.next(); } // 테넌트 컨텍스트 추출 const tenantContext = await multiTenantManager.extractTenantContext( request ); if (!tenantContext) { // 테넌트를 찾을 수 없는 경우 메인 앱으로 진행 return NextResponse.next(); } // 테넌트 라우팅 처리 return await multiTenantManager.handleTenantRouting(request, tenantContext); } catch (error) { console.error("멀티테넌트 미들웨어 오류:", error); return NextResponse.next(); } } // 테넌트 컨텍스트 헬퍼 함수들 export function getTenantId(request: NextRequest | Request): string | null { return request.headers.get("x-tenant-id"); } export function getTenantSlug(request: NextRequest | Request): string | null { return request.headers.get("x-tenant-slug"); } export function getTenantName(request: NextRequest | Request): string | null { return request.headers.get("x-tenant-name"); } export function getTenantDomain(request: NextRequest | Request): string | null { return request.headers.get("x-tenant-domain"); } export function getTenantSubdomain( request: NextRequest | Request ): string | null { return request.headers.get("x-tenant-subdomain"); } // React Server Component에서 사용할 테넌트 컨텍스트 추출 export function getTenantContext(headers: Headers): TenantContext | null { const siteId = headers.get("x-tenant-id"); const siteSlug = headers.get("x-tenant-slug"); const siteName = headers.get("x-tenant-name"); if (!siteId || !siteSlug || !siteName) { return null; } return { siteId, siteSlug, siteName, domain: headers.get("x-tenant-domain"), subdomain: headers.get("x-tenant-subdomain"), isPublished: true, // 미들웨어에서 이미 검증됨 }; }