From b6fe588a7cb625f55db5d2b6b6fdb4b011e88db5 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 26 Sep 2026 15:48:30 +0900 Subject: [PATCH] feat(web): serve the web app under /app and send every billing link there (WS-B) apps/web was never deployed, so /billing on the public domain returned the landing page and d3ro.dev (desktop "upgrade") did not resolve. - apps/web runs with basePath /app and output standalone; /download and /releases redirect to the site's #download. A Dockerfile and a d3ro-web compose service (port 3002) deploy it to the NAS with the other images. - The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host) and rewrites upstream redirects; everything else still goes to Pages. With no origin configured /app answers 503 instead of the landing page. - Desktop upgrade, desktop Stripe return, mobile subscription management, the web checkout/portal returns and the site all use billingUrl(); the return query is success=1 / canceled=1, which the billing page reads. The billing page highlights ?tier=pro|pro_plus, and signing in from a billing link returns to the same plan. - auth/callback pins the redirect origin in production and rejects protocol-relative next= values (open redirect). - Mobile legal links use SITE_URLS (fixes the missing slash on /terms). - Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal mounts; deploy scripts add the web image and the SUPABASE_* values the NAS compose already required; .dockerignore keeps app .env files out of images. - Supabase auth redirects allow /app/** (remote dashboard must match). Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4. --- .dockerignore | 4 + apps/desktop/src/main/ipc/license-handlers.ts | 9 +- apps/desktop/src/main/ipc/payment-handlers.ts | 5 +- .../tests/unit/license-open-billing.spec.ts | 50 +++++++++++ .../tests/unit/payment-handlers.spec.ts | 4 +- .../src/features/teams/team-service.ts | 7 +- apps/mobile-rn/src/lib/billing-context.tsx | 3 +- .../src/screens/ProPaywallScreen.tsx | 5 +- apps/mobile-rn/src/screens/SettingsScreen.tsx | 7 +- apps/mobile-rn/src/screens/SignUpScreen.tsx | 5 +- apps/web/Dockerfile | 45 ++++++++++ apps/web/env.example.txt | 4 +- apps/web/next.config.mjs | 12 --- apps/web/next.config.ts | 26 ++++++ apps/web/src/app/(app)/billing/page.tsx | 25 ++++-- apps/web/src/app/(app)/layout.tsx | 9 +- apps/web/src/app/auth/callback/route.ts | 28 +++++-- apps/web/src/app/login/page.tsx | 15 +++- .../components/billing/checkout-button.tsx | 5 +- .../billing/payple-checkout-button.tsx | 3 +- .../src/components/billing/portal-button.tsx | 3 +- apps/web/src/lib/web-app-url.ts | 45 ++++++++++ apps/web/src/proxy.ts | 18 ++++ docker-compose.nas.yml | 32 ++++++-- docker-compose.yml | 30 ++++++- scripts/deploy-nas.ps1 | 51 ++++++++++-- scripts/deploy-nas.sh | 49 +++++++++-- server/cloudflare-site-bridge/src/index.ts | 82 +++++++++++++++---- server/cloudflare-site-bridge/wrangler.toml | 5 ++ server/supabase/config.toml | 2 + 30 files changed, 493 insertions(+), 95 deletions(-) create mode 100644 apps/desktop/tests/unit/license-open-billing.spec.ts create mode 100644 apps/web/Dockerfile delete mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/src/lib/web-app-url.ts create mode 100644 apps/web/src/proxy.ts diff --git a/.dockerignore b/.dockerignore index 181db27..23fef26 100644 --- a/.dockerignore +++ b/.dockerignore @@ -26,3 +26,7 @@ obj/ *.db-wal .env .env.local + +# 앱별 로컬 환경 파일(비밀값)이 이미지 빌드 컨텍스트에 들어가지 않게 한다. +**/.env +**/.env.* diff --git a/apps/desktop/src/main/ipc/license-handlers.ts b/apps/desktop/src/main/ipc/license-handlers.ts index c359fb5..bcecdbd 100644 --- a/apps/desktop/src/main/ipc/license-handlers.ts +++ b/apps/desktop/src/main/ipc/license-handlers.ts @@ -11,6 +11,10 @@ import type { LicenseInfo, } from '@d3ro/core/types' import { Feature } from '@d3ro/core/types' +import { billingUrl, PUBLIC_SITE_ORIGIN } from '@d3ro/core/web-urls' + +/** 개발 빌드는 로컬 웹앱(`npm run dev --workspace=@d3ro/web`)의 결제 페이지를 연다. */ +const DEV_WEB_ORIGIN = 'http://localhost:3000' /** 업그레이드 유도 이벤트를 모든 렌더러에 broadcast */ function broadcastUpgradePrompt(event: UpgradePromptEvent): void { @@ -100,9 +104,8 @@ export function registerLicenseHandlers(): void { ipcMain.handle(IPC_CHANNELS.LICENSE.OPEN_BILLING, async (_event, params: { tier: 'pro' | 'pro_plus' }) => { try { - const baseUrl = app.isPackaged ? 'https://d3ro.dev' : 'http://localhost:3000' - const billingUrl = `${baseUrl}/billing?tier=${params.tier}` - await shell.openExternal(billingUrl) + const url = billingUrl({ tier: params.tier }) + await shell.openExternal(app.isPackaged ? url : url.replace(PUBLIC_SITE_ORIGIN, DEV_WEB_ORIGIN)) return ipcSuccess(undefined) } catch (err) { return ipcError(ErrorCode.UnknownError, `Failed to open billing: ${err}`) diff --git a/apps/desktop/src/main/ipc/payment-handlers.ts b/apps/desktop/src/main/ipc/payment-handlers.ts index 104b1a5..3579644 100644 --- a/apps/desktop/src/main/ipc/payment-handlers.ts +++ b/apps/desktop/src/main/ipc/payment-handlers.ts @@ -6,12 +6,13 @@ import { ipcMain } from 'electron' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { ErrorCode, ipcError, ok, type IPCResult } from '@d3ro/core/errors' import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types' +import { billingUrl } from '@d3ro/core/web-urls' import { getCloudSyncService } from '../services/CloudSyncService' const CHECKOUT_FUNCTION = 'stripe-checkout' const SUBSCRIPTION_FUNCTION = 'payple-manage' -const CHECKOUT_SUCCESS_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=success' -const CHECKOUT_CANCEL_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled' +const CHECKOUT_SUCCESS_URL = billingUrl({ result: 'success' }) +const CHECKOUT_CANCEL_URL = billingUrl({ result: 'canceled' }) const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com' const STRIPE_CHECKOUT_PATH_PREFIX = '/c/pay/' const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i diff --git a/apps/desktop/tests/unit/license-open-billing.spec.ts b/apps/desktop/tests/unit/license-open-billing.spec.ts new file mode 100644 index 0000000..c7cad14 --- /dev/null +++ b/apps/desktop/tests/unit/license-open-billing.spec.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { app, ipcMain, shell } from 'electron' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' + +vi.mock('../../src/main/services/LicenseService', () => ({ + getLicenseService: () => ({ on: vi.fn() }) +})) + +import { registerLicenseHandlers } from '../../src/main/ipc/license-handlers' + +type CapturedHandler = (...args: unknown[]) => unknown + +const handlers = new Map() + +async function openBilling(tier: 'pro' | 'pro_plus'): Promise { + const handler = handlers.get(IPC_CHANNELS.LICENSE.OPEN_BILLING) + if (!handler) throw new Error('Missing OPEN_BILLING handler') + return handler({}, { tier }) +} + +function setPackaged(value: boolean): void { + Object.defineProperty(app, 'isPackaged', { value, configurable: true, writable: true }) +} + +beforeEach(() => { + vi.clearAllMocks() + handlers.clear() + vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: CapturedHandler) => { + handlers.set(channel, handler) + }) + registerLicenseHandlers() +}) + +describe('LICENSE.OPEN_BILLING', () => { + it('opens the single web billing entry with the chosen tier in packaged builds', async () => { + setPackaged(true) + try { + await expect(openBilling('pro_plus')).resolves.toEqual({ success: true, data: undefined }) + expect(shell.openExternal).toHaveBeenCalledWith('https://d3ro.chanpaca.net/app/billing?tier=pro_plus') + } finally { + setPackaged(false) + } + }) + + it('opens the local web app under the same base path in development', async () => { + setPackaged(false) + await openBilling('pro') + expect(shell.openExternal).toHaveBeenCalledWith('http://localhost:3000/app/billing?tier=pro') + }) +}) diff --git a/apps/desktop/tests/unit/payment-handlers.spec.ts b/apps/desktop/tests/unit/payment-handlers.spec.ts index 994a389..83c9778 100644 --- a/apps/desktop/tests/unit/payment-handlers.spec.ts +++ b/apps/desktop/tests/unit/payment-handlers.spec.ts @@ -85,8 +85,8 @@ describe('desktop payment IPC security boundary', () => { expect(name).toBe('stripe-checkout') expect(body).toEqual({ tier: 'pro', - success_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=success', - cancel_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled', + success_url: 'https://d3ro.chanpaca.net/app/billing?success=1', + cancel_url: 'https://d3ro.chanpaca.net/app/billing?canceled=1', idempotency_key: expect.stringMatching(/^desktop:stripe-checkout:[0-9a-f-]{36}$/) }) expect(options).toMatchObject({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS }) diff --git a/apps/mobile-rn/src/features/teams/team-service.ts b/apps/mobile-rn/src/features/teams/team-service.ts index f61a1ec..9ca90cf 100644 --- a/apps/mobile-rn/src/features/teams/team-service.ts +++ b/apps/mobile-rn/src/features/teams/team-service.ts @@ -1,5 +1,6 @@ import type { RealtimeChannel } from '@supabase/supabase-js' import type { Meeting, Team, TeamRole } from '@d3ro/api-client' +import { PUBLIC_SITE_ORIGIN, SITE_URLS } from '@d3ro/core/web-urls' import { supabase } from '../../lib/supabase' export interface TeamSummary extends Team { @@ -99,7 +100,9 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32}$/ const TEAM_NAME_MAX_LENGTH = 80 const EMAIL_MAX_LENGTH = 254 -const TRUSTED_INVITE_ORIGIN = 'https://d3ro.chanpaca.net' +const TRUSTED_INVITE_ORIGIN = PUBLIC_SITE_ORIGIN +// '/accept-invite' — 사이트 정본 URL에서 경로만 떼어 끝 슬래시 유무를 모두 허용한다. +const INVITE_PATH = SITE_URLS.acceptInvite.slice(PUBLIC_SITE_ORIGIN.length).replace(/\/$/, '') function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -181,7 +184,7 @@ export function normalizeInviteUrl(value: string): string { } if ( parsed.origin !== TRUSTED_INVITE_ORIGIN - || (parsed.pathname !== '/accept-invite' && parsed.pathname !== '/accept-invite/') + || (parsed.pathname !== INVITE_PATH && parsed.pathname !== `${INVITE_PATH}/`) || parsed.username !== '' || parsed.password !== '' || parsed.hash !== '' diff --git a/apps/mobile-rn/src/lib/billing-context.tsx b/apps/mobile-rn/src/lib/billing-context.tsx index f708504..0fd64e2 100644 --- a/apps/mobile-rn/src/lib/billing-context.tsx +++ b/apps/mobile-rn/src/lib/billing-context.tsx @@ -9,6 +9,7 @@ import { type ReactNode, } from 'react' import { Linking, Platform } from 'react-native' +import { billingUrl } from '@d3ro/core/web-urls' import { ErrorCode, deepLinkToSubscriptions, @@ -541,7 +542,7 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re }) return } - await Linking.openURL('https://d3ro.chanpaca.net/billing') + await Linking.openURL(billingUrl()) }, [entitlement.snapshot.provider, entitlement.snapshot.storeProductId]) const clearOperation = useCallback(() => { diff --git a/apps/mobile-rn/src/screens/ProPaywallScreen.tsx b/apps/mobile-rn/src/screens/ProPaywallScreen.tsx index b779065..c615693 100644 --- a/apps/mobile-rn/src/screens/ProPaywallScreen.tsx +++ b/apps/mobile-rn/src/screens/ProPaywallScreen.tsx @@ -24,6 +24,7 @@ import { } from '../lib/mobile-ads-context' import { useMobilePreferences } from '../lib/preferences-context' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' +import { SITE_URLS } from '@d3ro/core/web-urls' type Navigation = NativeStackNavigationProp type TranslationKey = Parameters['t']>[0] @@ -305,7 +306,7 @@ export default function ProPaywallScreen(): React.ReactElement { void Linking.openURL('https://d3ro.chanpaca.net/terms')} + onPress={() => void Linking.openURL(SITE_URLS.terms)} style={styles.legalLink} > {t('mobile.paywall.terms')} @@ -314,7 +315,7 @@ export default function ProPaywallScreen(): React.ReactElement { void Linking.openURL('https://d3ro.chanpaca.net/privacy/')} + onPress={() => void Linking.openURL(SITE_URLS.privacy)} style={styles.legalLink} > {t('mobile.paywall.privacy')} diff --git a/apps/mobile-rn/src/screens/SettingsScreen.tsx b/apps/mobile-rn/src/screens/SettingsScreen.tsx index e168800..4aa063e 100644 --- a/apps/mobile-rn/src/screens/SettingsScreen.tsx +++ b/apps/mobile-rn/src/screens/SettingsScreen.tsx @@ -11,6 +11,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useNavigation } from '@react-navigation/native' import { useI18n, type TranslationKey } from '@d3ro/i18n' +import { SITE_URLS } from '@d3ro/core/web-urls' import { useAuth } from '../lib/auth-context' import { useEntitlement } from '../lib/entitlement-context' import { @@ -475,21 +476,21 @@ export default function SettingsScreen(): React.ReactElement { label={t('mobile.set.privacyPolicy')} hint={t('mobile.set.privacyPolicyHint')} testID="settings-privacy-policy" - onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/privacy/')} + onPress={() => void Linking.openURL(SITE_URLS.privacy)} /> void Linking.openURL('https://d3ro.chanpaca.net/terms/')} + onPress={() => void Linking.openURL(SITE_URLS.terms)} /> void Linking.openURL('https://d3ro.chanpaca.net/delete-account/')} + onPress={() => void Linking.openURL(SITE_URLS.deleteAccount)} /> diff --git a/apps/mobile-rn/src/screens/SignUpScreen.tsx b/apps/mobile-rn/src/screens/SignUpScreen.tsx index c867ab2..526528a 100644 --- a/apps/mobile-rn/src/screens/SignUpScreen.tsx +++ b/apps/mobile-rn/src/screens/SignUpScreen.tsx @@ -21,6 +21,7 @@ import { d3roNativeFonts, } from '@d3ro/ui-native' import { useI18n } from '@d3ro/i18n' +import { SITE_URLS } from '@d3ro/core/web-urls' import { isSupabaseConfigured, supabase } from '../lib/supabase' import { AUTH_REDIRECT_URL } from '../lib/auth-redirect' import { getAuthCapabilities, type AuthCapabilities } from '../lib/auth-capabilities' @@ -346,7 +347,7 @@ export default function SignUpScreen(): React.ReactElement { accessibilityRole="link" accessibilityLabel={t('mobile.set.termsOfService')} testID="sign-up-terms-link" - onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/terms/')} + onPress={() => void Linking.openURL(SITE_URLS.terms)} style={styles.legalLink} disabled={interfaceBusy} > @@ -361,7 +362,7 @@ export default function SignUpScreen(): React.ReactElement { accessibilityRole="link" accessibilityLabel={t('mobile.set.privacyPolicy')} testID="sign-up-privacy-link" - onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/privacy/')} + onPress={() => void Linking.openURL(SITE_URLS.privacy)} style={styles.legalLink} disabled={interfaceBusy} > diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..f05a4fb --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,45 @@ +# apps/web/Dockerfile +# 웹앱(@d3ro/web) standalone 이미지. 빌드 컨텍스트는 저장소 루트다. +# docker build -t d3ro-voice-web:latest -f apps/web/Dockerfile \ +# --build-arg NEXT_PUBLIC_SUPABASE_URL=... \ +# --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=... \ +# --build-arg NEXT_PUBLIC_PAYPLE_CLIENT_KEY=... . +# 공개 경로는 https://d3ro.chanpaca.net/app (Next basePath '/app', 정본 packages/core/src/web-urls.ts). +FROM node:24.19.0-alpine AS base + +FROM base AS builder +WORKDIR /app + +# NEXT_PUBLIC_* 는 next build 때 클라이언트·서버 번들에 박힌다. 런타임 환경변수로는 바뀌지 않는다. +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ARG NEXT_PUBLIC_PAYPLE_CLIENT_KEY="" +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL \ + NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY \ + NEXT_PUBLIC_PAYPLE_CLIENT_KEY=$NEXT_PUBLIC_PAYPLE_CLIENT_KEY \ + NEXT_TELEMETRY_DISABLED=1 +RUN test -n "$NEXT_PUBLIC_SUPABASE_URL" && test -n "$NEXT_PUBLIC_SUPABASE_ANON_KEY" \ + || (echo "NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY build args are required" >&2 && exit 1) + +COPY package*.json tsconfig*.json ./ +COPY packages ./packages +COPY apps/web ./apps/web +# 개발자 로컬 .env* 가 컨텍스트에 섞여 들어와도 빌드 값은 위 build arg 만 쓴다. +RUN rm -f apps/web/.env apps/web/.env.* +RUN npm ci +RUN npm run build --workspace=@d3ro/web + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3002 +ENV HOSTNAME="0.0.0.0" +ENV NEXT_TELEMETRY_DISABLED=1 + +# node 사용자로 실행하므로 .next/cache 쓰기가 가능하도록 소유권을 넘긴다. +COPY --from=builder --chown=node:node /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=node:node /app/apps/web/.next/static ./apps/web/.next/static + +USER node +EXPOSE 3002 +CMD ["node", "apps/web/server.js"] diff --git a/apps/web/env.example.txt b/apps/web/env.example.txt index 4680563..b83e087 100644 --- a/apps/web/env.example.txt +++ b/apps/web/env.example.txt @@ -7,8 +7,8 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key -# OAuth callback base URL -NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# 공개 주소는 @d3ro/core/web-urls 가 정본이다. 로컬 개발 주소는 http://localhost:3000/app (basePath). +# NEXT_PUBLIC_* 값은 빌드 시점에 번들에 들어간다. Docker 이미지는 --build-arg 로 넘긴다. # Payple browser SDK client key (test keys start with test_) # Missing values fail closed and disable the Payple checkout button. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs deleted file mode 100644 index 88a0f03..0000000 --- a/apps/web/next.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - reactStrictMode: true, - // monorepo workspace 패키지는 소스 TypeScript 그대로 번들 대상 - transpilePackages: ['@d3ro/core', '@d3ro/ui', '@d3ro/i18n', '@d3ro/api-client'], - experimental: { - // MUI + Emotion 최적화 - optimizePackageImports: ['@mui/material', '@mui/icons-material', '@d3ro/ui'] - } -} - -export default nextConfig diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..a6c3fc4 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,26 @@ +import type { NextConfig } from 'next' +import { SITE_URLS, WEB_APP_BASE_PATH } from '@d3ro/core/web-urls' + +const nextConfig: NextConfig = { + reactStrictMode: true, + // 웹앱은 d3ro.chanpaca.net/app 아래에서 서빙된다. 사이트 브리지 워커가 /app/* 를 이 서버로 보낸다. + basePath: WEB_APP_BASE_PATH, + // NAS Docker 이미지(apps/web/Dockerfile)는 standalone 산출물만 복사한다. + output: 'standalone', + // monorepo workspace 패키지는 소스 TypeScript 그대로 번들 대상 + transpilePackages: ['@d3ro/core', '@d3ro/ui', '@d3ro/i18n', '@d3ro/api-client'], + experimental: { + // MUI + Emotion 최적화 + optimizePackageImports: ['@mui/material', '@mui/icons-material', '@d3ro/ui'] + }, + async redirects() { + // 다운로드·릴리스 안내는 랜딩 사이트 한 벌만 둔다. source 는 basePath 가 붙어 /app/download, /app/releases(/…) 가 된다. + return ['/download', '/releases/:path*'].map((source) => ({ + source, + destination: SITE_URLS.download, + permanent: true + })) + } +} + +export default nextConfig diff --git a/apps/web/src/app/(app)/billing/page.tsx b/apps/web/src/app/(app)/billing/page.tsx index a529545..7f12bd9 100644 --- a/apps/web/src/app/(app)/billing/page.tsx +++ b/apps/web/src/app/(app)/billing/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { Alert, Box, Button, Chip, Stack, Typography } from '@mui/material' import { Check } from 'lucide-react' import type { Subscription, SubscriptionTier } from '@d3ro/api-client' +import type { PaidPlanTier } from '@d3ro/core/plan-catalog' import { MetalCard, TactileBadge } from '@d3ro/ui/components/ds' import { d3roFontMono } from '@d3ro/ui/theme' import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options' @@ -82,6 +83,11 @@ const PLANS: Plan[] = [ } ] +/** 데스크톱·모바일·사이트가 billingUrl({ tier }) 로 넘긴 요금제. 모르는 값은 무시한다. */ +function selectedTierFrom(value: string | string[] | undefined): PaidPlanTier | null { + return value === 'pro' || value === 'pro_plus' ? value : null +} + function tierLabel(tier: SubscriptionTier): string { return tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase() } @@ -160,6 +166,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P const periodEnd = formatDate(subscription.current_period_end) const stripeCanceled = params['canceled'] === '1' const stripeReturned = params['success'] === '1' + const selectedTier = selectedTierFrom(params['tier']) return ( @@ -244,6 +251,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P currentTier={subscription.tier} canPurchase={canPurchase} catalog={state.catalog} + selectedTier={selectedTier} /> ))} @@ -259,14 +267,19 @@ function PlanCard({ plan, currentTier, canPurchase, - catalog + catalog, + selectedTier }: { plan: Plan currentTier: SubscriptionTier canPurchase: boolean catalog: BillingCatalog | null + selectedTier: PaidPlanTier | null }): React.ReactElement { const active = plan.tier === currentTier + // 외부에서 고른 요금제가 있으면 그 카드만, 없으면 기본 추천 카드를 강조한다. + const selected = !active && selectedTier === plan.tier + const recommended = !active && !selectedTier && Boolean(plan.highlight) const catalogPrices: BillingCatalogPrice[] = plan.tier === 'free' || !catalog ? [] : catalog.plans[plan.tier] @@ -274,21 +287,23 @@ function PlanCard({ return ( - - {active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'} + + {active ? 'CURRENT PLAN' : selected ? 'SELECTED PLAN' : recommended ? 'RECOMMENDED' : 'PLAN'} {plan.name} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index b8cc79e..8c823dd 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -2,18 +2,23 @@ // 공유 레이아웃 — auth 가드 + Sidebar // route group `(app)`은 URL에 영향을 주지 않고 하위 모든 라우트에 적용. +import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { Box } from '@mui/material' import { Sidebar } from '@/components/layout/sidebar' import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server' +import { loginPath, RETURN_PATH_HEADER } from '@/lib/web-app-url' export default async function AppLayout({ children }: { children: React.ReactNode }): Promise { + // 결제 진입(/billing?tier=…)처럼 proxy 가 경로를 넘긴 요청은 로그인 뒤 그 경로로 돌아온다. + const returnPath = (await headers()).get(RETURN_PATH_HEADER) + if (!isSupabaseConfiguredServer()) { - redirect('/login') + redirect(loginPath(returnPath)) } const supabase = await getSupabaseServerClient() @@ -22,7 +27,7 @@ export default async function AppLayout({ } = await supabase.auth.getUser() if (!user) { - redirect('/login') + redirect(loginPath(returnPath)) } return ( diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts index fe24fe0..b03e66c 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -1,28 +1,40 @@ // apps/web/src/app/auth/callback/route.ts // OAuth 콜백 — provider에서 리다이렉트된 code를 session으로 교환. -import { NextResponse } from 'next/server' +import { NextResponse, type NextRequest } from 'next/server' +import { PUBLIC_SITE_ORIGIN } from '@d3ro/core/web-urls' import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server' +import { appUrlFor, safeReturnPath } from '@/lib/web-app-url' -export async function GET(request: Request): Promise { - const { searchParams, origin } = new URL(request.url) +/** + * 리다이렉트 기준 origin. + * 운영에서는 standalone 서버가 터널·브리지 워커 뒤에 있어 요청 host 가 내부 주소일 수 있으므로 + * 공개 주소로 고정한다. 개발(next dev)에서는 요청 origin(localhost:3000)을 쓴다. + */ +function redirectOrigin(request: NextRequest): string { + return process.env.NODE_ENV === 'production' ? PUBLIC_SITE_ORIGIN : request.nextUrl.origin +} + +export async function GET(request: NextRequest): Promise { + const { searchParams } = request.nextUrl + const origin = redirectOrigin(request) const code = searchParams.get('code') - const next = searchParams.get('next') ?? '/dashboard' + const next = safeReturnPath(searchParams.get('next')) if (!isSupabaseConfiguredServer()) { - return NextResponse.redirect(`${origin}/login?error=supabase_not_configured`) + return NextResponse.redirect(appUrlFor(origin, '/login?error=supabase_not_configured')) } if (!code) { - return NextResponse.redirect(`${origin}/login?error=no_code`) + return NextResponse.redirect(appUrlFor(origin, '/login?error=no_code')) } const supabase = await getSupabaseServerClient() const { error } = await supabase.auth.exchangeCodeForSession(code) if (error) { - return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error.message)}`) + return NextResponse.redirect(appUrlFor(origin, `/login?error=${encodeURIComponent(error.message)}`)) } - return NextResponse.redirect(`${origin}${next}`) + return NextResponse.redirect(appUrlFor(origin, next)) } diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 77333b7..6c75d15 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -11,6 +11,12 @@ import { Box, Stack, Alert } from '@mui/material' import { useI18n } from '@d3ro/i18n' import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser' import { useAuth } from '@/components/providers/auth-provider' +import { appUrlFor, safeReturnPath } from '@/lib/web-app-url' + +/** (app) 레이아웃이 붙여 보낸 `?next=` — 로그인 뒤 돌아갈 웹앱 내부 경로. */ +function returnPathFromLocation(): string { + return safeReturnPath(new URLSearchParams(window.location.search).get('next')) +} export default function LoginPage(): React.ReactElement { const router = useRouter() @@ -24,7 +30,7 @@ export default function LoginPage(): React.ReactElement { useEffect(() => { if (!loading && user) { - router.replace('/dashboard') + router.replace(returnPathFromLocation()) } }, [loading, user, router]) @@ -38,7 +44,10 @@ export default function LoginPage(): React.ReactElement { try { const supabase = getSupabaseBrowserClient() - const redirectTo = `${window.location.origin}/auth/callback` + const redirectTo = appUrlFor( + window.location.origin, + `/auth/callback?next=${encodeURIComponent(returnPathFromLocation())}` + ) const { error: err } = await supabase.auth.signInWithOAuth({ provider: provider === 'apple' ? 'apple' : provider, options: { redirectTo } @@ -69,7 +78,7 @@ export default function LoginPage(): React.ReactElement { setError(err.message) setSigningIn(false) } else { - router.replace('/dashboard') + router.replace(returnPathFromLocation()) } } catch (e) { setError(e instanceof Error ? e.message : 'Unknown error') diff --git a/apps/web/src/components/billing/checkout-button.tsx b/apps/web/src/components/billing/checkout-button.tsx index efd3edf..50eec32 100644 --- a/apps/web/src/components/billing/checkout-button.tsx +++ b/apps/web/src/components/billing/checkout-button.tsx @@ -6,6 +6,7 @@ import { useState } from 'react' import { Button, CircularProgress, Alert, Box } from '@mui/material' import { getSupabaseBrowserClient } from '@/lib/supabase-browser' +import { browserBillingUrl } from '@/lib/web-app-url' import type { PaypleTier } from './payple-client' interface CheckoutButtonProps { @@ -46,8 +47,8 @@ export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElemen body: JSON.stringify({ tier, idempotency_key: `stripe-checkout:${crypto.randomUUID()}`, - success_url: `${window.location.origin}/billing?success=1`, - cancel_url: `${window.location.origin}/billing?canceled=1` + success_url: browserBillingUrl('success'), + cancel_url: browserBillingUrl('canceled') }) } ) diff --git a/apps/web/src/components/billing/payple-checkout-button.tsx b/apps/web/src/components/billing/payple-checkout-button.tsx index 0493d20..145f256 100644 --- a/apps/web/src/components/billing/payple-checkout-button.tsx +++ b/apps/web/src/components/billing/payple-checkout-button.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react' import { Alert, Box, Button, CircularProgress } from '@mui/material' import Script from 'next/script' import { getSupabaseBrowserClient } from '@/lib/supabase-browser' +import { browserBillingUrl } from '@/lib/web-app-url' import { assertSuccessfulCheckoutResponse, clearPaypleIdempotencyKey, @@ -94,7 +95,7 @@ export function PaypleCheckoutButton({ tier, catalogPrice }: PaypleCheckoutButto email: session.user.email, tier, catalogPrice, - resultUrl: `${window.location.origin}/billing`, + resultUrl: browserBillingUrl(), openSdk: (request) => window.PaypleCpayAuthCheck(request), chargeBillingKey: async (payerId, result) => { const response = await fetch(checkoutEndpoint(), { diff --git a/apps/web/src/components/billing/portal-button.tsx b/apps/web/src/components/billing/portal-button.tsx index 9964a7a..81d0f7c 100644 --- a/apps/web/src/components/billing/portal-button.tsx +++ b/apps/web/src/components/billing/portal-button.tsx @@ -7,6 +7,7 @@ import { useState } from 'react' import { Button, CircularProgress, Alert, Box } from '@mui/material' import SettingsIcon from '@mui/icons-material/Settings' import { getSupabaseBrowserClient } from '@/lib/supabase-browser' +import { browserBillingUrl } from '@/lib/web-app-url' export function PortalButton(): React.ReactElement { const [busy, setBusy] = useState(false) @@ -39,7 +40,7 @@ export function PortalButton(): React.ReactElement { 'Content-Type': 'application/json' }, body: JSON.stringify({ - return_url: `${window.location.origin}/billing` + return_url: browserBillingUrl() }) } ) diff --git a/apps/web/src/lib/web-app-url.ts b/apps/web/src/lib/web-app-url.ts new file mode 100644 index 0000000..1ebd153 --- /dev/null +++ b/apps/web/src/lib/web-app-url.ts @@ -0,0 +1,45 @@ +// apps/web/src/lib/web-app-url.ts +// 웹앱 안에서 쓰는 절대 URL·복귀 경로 헬퍼. 주소 정본은 @d3ro/core/web-urls 다. +// +// 결제사(Stripe·Payple)와 OAuth 는 절대 URL 을 요구한다. 운영은 d3ro.chanpaca.net/app, +// 로컬 개발은 localhost:3000/app 이므로 origin 만 현재 브라우저 것으로 바꿔 끼운다. + +import { + billingUrl, + PUBLIC_SITE_ORIGIN, + WEB_APP_BASE_PATH, + type BillingReturn +} from '@d3ro/core/web-urls' + +/** 로그인 뒤 돌아갈 경로를 proxy 가 (app) 레이아웃에 넘길 때 쓰는 요청 헤더. */ +export const RETURN_PATH_HEADER = 'x-d3ro-return-path' + +const DEFAULT_RETURN_PATH = '/dashboard' + +/** + * 로그인 뒤 이동할 웹앱 내부 경로(basePath 제외)만 통과시킨다. + * `//evil.example`, `/\evil.example` 같은 외부 이동은 기본 경로로 바꾼다. + */ +export function safeReturnPath(value: string | null | undefined): string { + if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) { + return DEFAULT_RETURN_PATH + } + return value +} + +/** 로그인 페이지 경로(basePath 제외). 돌아갈 곳이 기본값이면 쿼리를 붙이지 않는다. */ +export function loginPath(returnPath: string | null | undefined): string { + const next = safeReturnPath(returnPath) + return next === DEFAULT_RETURN_PATH ? '/login' : `/login?next=${encodeURIComponent(next)}` +} + +/** `origin` 기준 웹앱 절대 URL. `path` 는 basePath 를 뺀 앱 경로('/billing' 등). */ +export function appUrlFor(origin: string, path: string): string { + return `${origin}${WEB_APP_BASE_PATH}${path}` +} + +/** 브라우저에서 결제 페이지 절대 URL. 쿼리 형식은 core billingUrl() 을 그대로 따른다. */ +export function browserBillingUrl(result?: BillingReturn): string { + const canonical = billingUrl(result ? { result } : {}) + return `${window.location.origin}${canonical.slice(PUBLIC_SITE_ORIGIN.length)}` +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts new file mode 100644 index 0000000..d00f572 --- /dev/null +++ b/apps/web/src/proxy.ts @@ -0,0 +1,18 @@ +// apps/web/src/proxy.ts +// 외부 진입점(데스크톱·모바일·사이트가 여는 /app/billing?tier=…)으로 들어온 요청의 경로를 +// (app) 레이아웃에 넘겨, 로그인이 필요할 때 로그인 뒤 같은 곳으로 돌아오게 한다. + +import { NextResponse, type NextRequest } from 'next/server' +import { RETURN_PATH_HEADER } from '@/lib/web-app-url' + +export function proxy(request: NextRequest): NextResponse { + const headers = new Headers(request.headers) + // nextUrl.pathname 은 basePath('/app')를 뺀 앱 경로다. + headers.set(RETURN_PATH_HEADER, `${request.nextUrl.pathname}${request.nextUrl.search}`) + return NextResponse.next({ request: { headers } }) +} + +export const config = { + // basePath 기준 경로. 외부에서 직접 여는 결제 진입점만 대상이다. + matcher: ['/billing'] +} diff --git a/docker-compose.nas.yml b/docker-compose.nas.yml index 1d80ba4..6e25f77 100644 --- a/docker-compose.nas.yml +++ b/docker-compose.nas.yml @@ -1,6 +1,7 @@ # ============================================================================ # D3RO Voice — Standalone NAS Docker Compose Specification -# Includes: Core API + Promotional Site + Full Next.js Admin CRM +# Includes: Core API + Next.js Admin CRM + Web App (/app) +# 랜딩·법률 문서는 site/(Cloudflare Pages)가 정본이다. 여기서 서빙하지 않는다. # ============================================================================ services: d3ro-api-server: @@ -23,12 +24,6 @@ services: - TZ=${TZ:-Asia/Seoul} volumes: - ${DATA_PATH:-./data}:/app/data - # Play policy/legal endpoints must survive API image recreation without - # replacing unrelated static assets embedded in the running image. - - /volume1/docker/d3ro/wwwroot/privacy:/app/wwwroot/privacy:ro - - /volume1/docker/d3ro/wwwroot/terms:/app/wwwroot/terms:ro - - /volume1/docker/d3ro/wwwroot/delete-account:/app/wwwroot/delete-account:ro - - /volume1/docker/d3ro/wwwroot/legal.css:/app/wwwroot/legal.css:ro logging: driver: "json-file" options: @@ -44,7 +39,6 @@ services: environment: - NODE_ENV=production - PORT=3001 - - NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net} - API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required} - ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true} @@ -57,3 +51,25 @@ services: options: max-size: "10m" max-file: "3" + + # ============================================================================ + # D3RO Voice — Web App (@d3ro/web), 공개 경로 https://d3ro.chanpaca.net/app + # 사이트 브리지 워커가 /app/* 를 Cloudflare Tunnel 을 거쳐 이 컨테이너로 보낸다. + # ============================================================================ + d3ro-web: + image: d3ro-voice-web:latest + container_name: d3ro_voice_web + restart: unless-stopped + ports: + - "${WEB_PORT:-3002}:3002" + # NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY / NEXT_PUBLIC_PAYPLE_CLIENT_KEY 는 + # 이미지 빌드 때 번들에 들어간다(build args). 런타임에 넣어도 바뀌지 않으므로 여기 두지 않는다. + environment: + - NODE_ENV=production + - PORT=3002 + - TZ=${TZ:-Asia/Seoul} + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/docker-compose.yml b/docker-compose.yml index b9e0f07..86478e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,7 +46,6 @@ services: environment: - NODE_ENV=production - PORT=3001 - - NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net} - API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required} - ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true} @@ -58,6 +57,35 @@ services: max-size: "10m" max-file: "3" + # ============================================================================ + # D3RO Voice — Web App (@d3ro/web), 공개 경로 https://d3ro.chanpaca.net/app + # 사이트 브리지 워커가 /app/* 를 Cloudflare Tunnel 을 거쳐 이 컨테이너로 보낸다. + # ============================================================================ + d3ro-web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:-} + NEXT_PUBLIC_PAYPLE_CLIENT_KEY: ${NEXT_PUBLIC_PAYPLE_CLIENT_KEY:-} + image: d3ro-voice-web:latest + container_name: d3ro_voice_web + restart: unless-stopped + ports: + - "${WEB_PORT:-3002}:3002" + # NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY / NEXT_PUBLIC_PAYPLE_CLIENT_KEY 는 + # 이미지 빌드 때 번들에 들어간다(build args). 런타임에 넣어도 바뀌지 않으므로 여기 두지 않는다. + environment: + - NODE_ENV=production + - PORT=3002 + - TZ=${TZ:-Asia/Seoul} + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + # ============================================================================ # Optional: Local AI Engine on NAS (Ollama) # Run with: docker compose --profile ai up -d diff --git a/scripts/deploy-nas.ps1 b/scripts/deploy-nas.ps1 index 2141c20..0671287 100644 --- a/scripts/deploy-nas.ps1 +++ b/scripts/deploy-nas.ps1 @@ -58,7 +58,7 @@ if (-not (Test-Path $nasPkgDir)) { # 2. Build Docker Image if (-not $SkipBuild) { - Write-Host "`n[1/4] Building D3RO Voice API & Promotion Site Image..." -ForegroundColor Yellow + Write-Host "`n[1/4] Building D3RO Voice API Image..." -ForegroundColor Yellow docker build -t d3ro-voice-api:latest ./apps/api-server if ($LASTEXITCODE -ne 0) { Write-Error "API Docker image build failed!" @@ -73,6 +73,30 @@ if (-not $SkipBuild) { exit 1 } Write-Host " Docker image built: d3ro-voice-admin:latest" -ForegroundColor Green + + # Web app (@d3ro/web, served at /app). NEXT_PUBLIC_* values are baked in at build time. + Write-Host "`n[1.6/4] Building D3RO Voice Next.js Web App Image..." -ForegroundColor Yellow + $webBuildArgs = @() + foreach ($name in @("NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY", "NEXT_PUBLIC_PAYPLE_CLIENT_KEY")) { + $value = if ($envDict.ContainsKey($name)) { $envDict[$name] } else { [Environment]::GetEnvironmentVariable($name) } + if ([string]::IsNullOrWhiteSpace($value)) { + if ($name -eq "NEXT_PUBLIC_PAYPLE_CLIENT_KEY") { + Write-Warning "$name is empty: the web app will build with Payple checkout disabled." + continue + } + throw "$name is required to build the web app image." + } + if ($value.Contains("`r") -or $value.Contains("`n")) { + throw "$name must be a single-line value." + } + $webBuildArgs += @("--build-arg", "$name=$value") + } + docker build -t d3ro-voice-web:latest -f apps/web/Dockerfile @webBuildArgs . + if ($LASTEXITCODE -ne 0) { + Write-Error "Web Docker image build failed!" + exit 1 + } + Write-Host " Docker image built: d3ro-voice-web:latest" -ForegroundColor Green } else { Write-Host "`n[1/4] Skipping Docker build (-SkipBuild specified)" -ForegroundColor Gray } @@ -80,10 +104,12 @@ if (-not $SkipBuild) { # 3. Export Docker Image Archives $tarPath = Join-Path $nasPkgDir "d3ro-voice-api.tar" $adminTarPath = Join-Path $nasPkgDir "d3ro-voice-admin.tar" +$webTarPath = Join-Path $nasPkgDir "d3ro-voice-web.tar" Write-Host "`n[2/4] Exporting Docker Images to Archives..." -ForegroundColor Yellow docker save -o $tarPath d3ro-voice-api:latest docker save -o $adminTarPath d3ro-voice-admin:latest -Write-Host " Image archives exported: $tarPath, $adminTarPath" -ForegroundColor Green +docker save -o $webTarPath d3ro-voice-web:latest +Write-Host " Image archives exported: $tarPath, $adminTarPath, $webTarPath" -ForegroundColor Green # 4. Prepare Standalone NAS Package Write-Host "`n[3/4] Packaging NAS deployment files..." -ForegroundColor Yellow @@ -102,7 +128,9 @@ $requiredDeploymentSecrets = @( "ADMIN_SESSION_SECRET", "API_SERVER_URL", "CORS_ALLOWED_ORIGINS", - "ALLOWED_HOSTS" + "ALLOWED_HOSTS", + "SUPABASE_URL", + "SUPABASE_SERVICE_ROLE_KEY" ) $deploymentSecrets = @{} foreach ($name in $requiredDeploymentSecrets) { @@ -130,6 +158,8 @@ ADMIN_SESSION_SECRET=$($deploymentSecrets["ADMIN_SESSION_SECRET"]) API_SERVER_URL=$($deploymentSecrets["API_SERVER_URL"]) CORS_ALLOWED_ORIGINS=$($deploymentSecrets["CORS_ALLOWED_ORIGINS"]) ALLOWED_HOSTS=$($deploymentSecrets["ALLOWED_HOSTS"]) +SUPABASE_URL=$($deploymentSecrets["SUPABASE_URL"]) +SUPABASE_SERVICE_ROLE_KEY=$($deploymentSecrets["SUPABASE_SERVICE_ROLE_KEY"]) TZ=Asia/Seoul "@ $deploymentEnvPath = Join-Path $nasPkgDir ".env" @@ -154,14 +184,16 @@ $readmeContent = @" [Synology / QNAP / Linux NAS 배포 방법] -1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, docker-compose.yml, .env, nas-control.sh)을 - NAS의 Docker 작업 폴더 ($NasPath)에 업로드합니다. +1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, d3ro-voice-admin.tar, d3ro-voice-web.tar, + docker-compose.yml, .env, nas-control.sh)을 NAS의 Docker 작업 폴더 ($NasPath)에 업로드합니다. 2. NAS SSH 터미널에 접속하여 해당 폴더로 이동합니다: cd $NasPath 3. Docker 이미지를 로드합니다: docker load < d3ro-voice-api.tar + docker load < d3ro-voice-admin.tar + docker load < d3ro-voice-web.tar 4. 서비스를 시작합니다: docker compose up -d @@ -171,6 +203,7 @@ $readmeContent = @" - 관리자 백오피스: http://:$Port/admin - API Swagger: http://:$Port/swagger - 헬스체크: http://:$Port/health + - 웹앱: http://:3002/app/login (공개 주소 https://d3ro.chanpaca.net/app 는 사이트 브리지 워커 경유) "@ Set-Content -Path (Join-Path $nasPkgDir "README.txt") -Value $readmeContent -Encoding UTF8 @@ -187,16 +220,16 @@ if ($DeploySsh -and $NasHost) { ssh -p $NasSshPort "$NasUser@$NasHost" "mkdir -p $NasPath/data" Write-Host " [2/3] Uploading deployment package to NAS (SCP)..." -ForegroundColor Gray - scp -O -P $NasSshPort "$tarPath" "$adminTarPath" (Join-Path $nasPkgDir "docker-compose.yml") (Join-Path $nasPkgDir ".env") (Join-Path $nasPkgDir "nas-control.sh") "$NasUser@$($NasHost):$NasPath/" + scp -O -P $NasSshPort "$tarPath" "$adminTarPath" "$webTarPath" (Join-Path $nasPkgDir "docker-compose.yml") (Join-Path $nasPkgDir ".env") (Join-Path $nasPkgDir "nas-control.sh") "$NasUser@$($NasHost):$NasPath/" Write-Host " [3/3] Loading Docker images and launching services on NAS..." -ForegroundColor Gray - ssh -p $NasSshPort "$NasUser@$NasHost" "cd $NasPath && chmod +x nas-control.sh 2>/dev/null; docker load < d3ro-voice-api.tar && docker load < d3ro-voice-admin.tar && docker compose down 2>/dev/null; docker compose up -d" + ssh -p $NasSshPort "$NasUser@$NasHost" "cd $NasPath && chmod +x nas-control.sh 2>/dev/null; docker load < d3ro-voice-api.tar && docker load < d3ro-voice-admin.tar && docker load < d3ro-voice-web.tar && docker compose down 2>/dev/null; docker compose up -d" Write-Host "`n========================================================" -ForegroundColor Green Write-Host " NAS Deployment Completed Successfully!" -ForegroundColor Green Write-Host " Access your D3RO Cloud Services at:" -ForegroundColor Cyan - Write-Host " - Official Promotion Site: http://$($NasHost):$Port/" -ForegroundColor White Write-Host " - Next.js Admin CRM: http://$($NasHost):3001/" -ForegroundColor White + Write-Host " - Next.js Web App: http://$($NasHost):3002/app/login" -ForegroundColor White Write-Host " - Swagger API Docs: http://$($NasHost):$Port/swagger" -ForegroundColor White Write-Host " - Health Check: http://$($NasHost):$Port/health" -ForegroundColor White Write-Host "========================================================" -ForegroundColor Green @@ -206,6 +239,8 @@ if ($DeploySsh -and $NasHost) { Write-Host " Output Folder: $nasPkgDir" -ForegroundColor White Write-Host " Files generated:" -ForegroundColor White Write-Host " - d3ro-voice-api.tar (Docker image archive)" -ForegroundColor Gray + Write-Host " - d3ro-voice-admin.tar (Docker image archive)" -ForegroundColor Gray + Write-Host " - d3ro-voice-web.tar (Docker image archive)" -ForegroundColor Gray Write-Host " - docker-compose.yml (NAS Compose configuration)" -ForegroundColor Gray Write-Host " - .env (Environment variables)" -ForegroundColor Gray Write-Host " - nas-control.sh (Container management script)" -ForegroundColor Gray diff --git a/scripts/deploy-nas.sh b/scripts/deploy-nas.sh index 3f54a46..fb7fd2c 100644 --- a/scripts/deploy-nas.sh +++ b/scripts/deploy-nas.sh @@ -15,6 +15,11 @@ set -e : "${API_SERVER_URL:?API_SERVER_URL is required}" : "${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required}" : "${ALLOWED_HOSTS:?ALLOWED_HOSTS is required}" +: "${SUPABASE_URL:?SUPABASE_URL is required}" +: "${SUPABASE_SERVICE_ROLE_KEY:?SUPABASE_SERVICE_ROLE_KEY is required}" +# Web app (@d3ro/web) build args — NEXT_PUBLIC_* values are baked into the bundle at build time. +: "${NEXT_PUBLIC_SUPABASE_URL:?NEXT_PUBLIC_SUPABASE_URL is required}" +: "${NEXT_PUBLIC_SUPABASE_ANON_KEY:?NEXT_PUBLIC_SUPABASE_ANON_KEY is required}" for value in \ "$JWT_SECRET" \ @@ -25,7 +30,12 @@ for value in \ "$ADMIN_SESSION_SECRET" \ "$API_SERVER_URL" \ "$CORS_ALLOWED_ORIGINS" \ - "$ALLOWED_HOSTS"; do + "$ALLOWED_HOSTS" \ + "$SUPABASE_URL" \ + "$SUPABASE_SERVICE_ROLE_KEY" \ + "$NEXT_PUBLIC_SUPABASE_URL" \ + "$NEXT_PUBLIC_SUPABASE_ANON_KEY" \ + "${NEXT_PUBLIC_PAYPLE_CLIENT_KEY:-}"; do case "$value" in *$'\n'*|*$'\r'*) echo "Deployment values must be single-line" >&2; exit 1 ;; esac @@ -61,10 +71,29 @@ echo -e "\n${YELLOW}[1/4] Building D3RO Voice API & BackOffice Docker Image...${ docker build -t d3ro-voice-api:latest -f "$ROOT_DIR/apps/api-server/Dockerfile" "$ROOT_DIR/apps/api-server" echo -e "${GREEN}Docker image built: d3ro-voice-api:latest${NC}" +echo -e "\n${YELLOW}[1.5/4] Building D3RO Voice Next.js Admin CRM Image...${NC}" +docker build -t d3ro-voice-admin:latest -f "$ROOT_DIR/apps/admin/Dockerfile" "$ROOT_DIR" +echo -e "${GREEN}Docker image built: d3ro-voice-admin:latest${NC}" + +echo -e "\n${YELLOW}[1.6/4] Building D3RO Voice Next.js Web App Image (/app)...${NC}" +if [ -z "${NEXT_PUBLIC_PAYPLE_CLIENT_KEY:-}" ]; then + echo -e "${YELLOW}NEXT_PUBLIC_PAYPLE_CLIENT_KEY is empty: Payple checkout will be disabled in the web app.${NC}" +fi +docker build -t d3ro-voice-web:latest -f "$ROOT_DIR/apps/web/Dockerfile" \ + --build-arg "NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL" \ + --build-arg "NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY" \ + --build-arg "NEXT_PUBLIC_PAYPLE_CLIENT_KEY=${NEXT_PUBLIC_PAYPLE_CLIENT_KEY:-}" \ + "$ROOT_DIR" +echo -e "${GREEN}Docker image built: d3ro-voice-web:latest${NC}" + TAR_PATH="$OUT_DIR/d3ro-voice-api.tar" -echo -e "\n${YELLOW}[2/4] Exporting Docker Image to Archive ($TAR_PATH)...${NC}" +ADMIN_TAR_PATH="$OUT_DIR/d3ro-voice-admin.tar" +WEB_TAR_PATH="$OUT_DIR/d3ro-voice-web.tar" +echo -e "\n${YELLOW}[2/4] Exporting Docker Images to Archives...${NC}" docker save -o "$TAR_PATH" d3ro-voice-api:latest -echo -e "${GREEN}Image archive exported: $TAR_PATH${NC}" +docker save -o "$ADMIN_TAR_PATH" d3ro-voice-admin:latest +docker save -o "$WEB_TAR_PATH" d3ro-voice-web:latest +echo -e "${GREEN}Image archives exported: $TAR_PATH, $ADMIN_TAR_PATH, $WEB_TAR_PATH${NC}" echo -e "\n${YELLOW}[3/4] Packaging NAS deployment files...${NC}" cp "$ROOT_DIR/docker-compose.nas.yml" "$OUT_DIR/docker-compose.yml" @@ -83,6 +112,8 @@ ADMIN_SESSION_SECRET=$ADMIN_SESSION_SECRET API_SERVER_URL=$API_SERVER_URL CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS ALLOWED_HOSTS=$ALLOWED_HOSTS +SUPABASE_URL=$SUPABASE_URL +SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY TZ=Asia/Seoul EOF chmod 600 "$OUT_DIR/.env" @@ -94,14 +125,16 @@ cat < "$OUT_DIR/README.txt" [Synology / QNAP / Linux NAS 배포 방법] -1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, docker-compose.yml, .env, nas-control.sh)을 - NAS의 Docker 작업 폴더 (예: /volume1/docker/d3ro)에 업로드합니다. +1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, d3ro-voice-admin.tar, d3ro-voice-web.tar, + docker-compose.yml, .env, nas-control.sh)을 NAS의 Docker 작업 폴더 (예: /volume1/docker/d3ro)에 업로드합니다. 2. NAS SSH 터미널에 접속하여 해당 폴더로 이동합니다: cd /volume1/docker/d3ro 3. Docker 이미지를 로드합니다: docker load < d3ro-voice-api.tar + docker load < d3ro-voice-admin.tar + docker load < d3ro-voice-web.tar 4. 서비스를 시작합니다: ./nas-control.sh start (또는 docker compose up -d) @@ -111,6 +144,7 @@ cat < "$OUT_DIR/README.txt" - 관리자 백오피스: http://:$PORT/admin - API Swagger: http://:$PORT/swagger - 헬스체크: http://:$PORT/health + - 웹앱: http://:3002/app/login (공개 주소 https://d3ro.chanpaca.net/app 는 사이트 브리지 워커 경유) EOF echo -e "${GREEN}NAS Deployment Package ready at: $OUT_DIR${NC}" @@ -118,8 +152,8 @@ echo -e "${GREEN}NAS Deployment Package ready at: $OUT_DIR${NC}" if [ -n "$NAS_HOST" ]; then echo -e "\n${YELLOW}[4/4] Deploying to Remote NAS ($NAS_USER@$NAS_HOST:$NAS_PATH)...${NC}" ssh "$NAS_USER@$NAS_HOST" "mkdir -p $NAS_PATH/data" - scp -O "$TAR_PATH" "$OUT_DIR/docker-compose.yml" "$OUT_DIR/.env" "$OUT_DIR/nas-control.sh" "$NAS_USER@$NAS_HOST:$NAS_PATH/" - ssh "$NAS_USER@$NAS_HOST" "cd $NAS_PATH && chmod +x nas-control.sh && docker load < d3ro-voice-api.tar && docker compose down 2>/dev/null || true; docker compose up -d" + scp -O "$TAR_PATH" "$ADMIN_TAR_PATH" "$WEB_TAR_PATH" "$OUT_DIR/docker-compose.yml" "$OUT_DIR/.env" "$OUT_DIR/nas-control.sh" "$NAS_USER@$NAS_HOST:$NAS_PATH/" + ssh "$NAS_USER@$NAS_HOST" "cd $NAS_PATH && chmod +x nas-control.sh && docker load < d3ro-voice-api.tar && docker load < d3ro-voice-admin.tar && docker load < d3ro-voice-web.tar && docker compose down 2>/dev/null || true; docker compose up -d" echo -e "\n${GREEN}========================================================${NC}" echo -e "${GREEN} NAS Deployment Completed Successfully!${NC}" @@ -128,6 +162,7 @@ if [ -n "$NAS_HOST" ]; then echo -e " - Admin: http://$NAS_HOST:$PORT/admin" echo -e " - Swagger: http://$NAS_HOST:$PORT/swagger" echo -e " - Health: http://$NAS_HOST:$PORT/health" + echo -e " - Web App: http://$NAS_HOST:3002/app/login" echo -e "${GREEN}========================================================${NC}" else echo -e "\n${GREEN}========================================================${NC}" diff --git a/server/cloudflare-site-bridge/src/index.ts b/server/cloudflare-site-bridge/src/index.ts index a1b34e8..e73f4a3 100644 --- a/server/cloudflare-site-bridge/src/index.ts +++ b/server/cloudflare-site-bridge/src/index.ts @@ -1,29 +1,75 @@ // server/cloudflare-site-bridge/src/index.ts -// d3ro.chanpaca.net → Cloudflare Pages(d3ro.pages.dev) 프록시. +// d3ro.chanpaca.net 한 도메인 아래 두 앱을 잇는 브리지. // +// /app, /app/* → 웹앱(apps/web, NAS 컨테이너) — WEB_APP_ORIGIN(터널 호스트) +// 그 밖 → 랜딩 사이트(site/, Cloudflare Pages) +// +// 경로 규칙의 정본은 packages/core/src/web-urls.ts 의 WEB_APP_BASE_PATH 다. // Pages 커스텀 도메인은 존 DNS에 CNAME을 요구하므로, DNS를 건드릴 수 없는 동안 -// 이 워커가 도메인을 살린다. 콘텐츠 정본은 Pages 배포본 하나이므로 CI가 Pages에 -// 배포하면 도메인에도 그대로 반영된다. +// 이 워커가 도메인을 살린다. + +import { WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls' const PAGES_ORIGIN = 'https://d3ro.pages.dev' +interface Env { + /** 웹앱 컨테이너를 노출한 터널 호스트(예: https://d3ro-app.chanpaca.net). 비어 있으면 /app 은 503. */ + WEB_APP_ORIGIN?: string +} + +function isWebAppPath(pathname: string): boolean { + return pathname === WEB_APP_BASE_PATH || pathname.startsWith(`${WEB_APP_BASE_PATH}/`) +} + +async function proxy(request: Request, origin: string, extraHeaders: Record = {}): Promise { + const incoming = new URL(request.url) + const upstream = new URL(origin) + const target = new URL(incoming.pathname + incoming.search, upstream) + + const headers = new Headers(request.headers) + headers.delete('host') + for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value) + + const hasBody = request.method !== 'GET' && request.method !== 'HEAD' + const response = await fetch(target.toString(), { + method: request.method, + headers, + body: hasBody ? request.body : undefined, + redirect: 'manual', + }) + + // 원본 호스트로 나가는 리다이렉트를 공개 도메인으로 되돌린다. + const location = response.headers.get('location') + if (location) { + const resolved = new URL(location, target) + if (resolved.host === upstream.host) { + resolved.protocol = incoming.protocol + resolved.host = incoming.host + const rewritten = new Response(response.body, response) + rewritten.headers.set('location', resolved.toString()) + return rewritten + } + } + return response +} + export default { - async fetch(request: Request): Promise { - const target = new URL(request.url) - target.protocol = 'https:' - target.hostname = new URL(PAGES_ORIGIN).hostname - target.port = '' + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) - const headers = new Headers(request.headers) - headers.delete('host') + if (isWebAppPath(url.pathname)) { + if (!env.WEB_APP_ORIGIN) { + return new Response('Web app is not available yet.', { + status: 503, + headers: { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '3600' }, + }) + } + return proxy(request, env.WEB_APP_ORIGIN, { + 'x-forwarded-host': url.host, + 'x-forwarded-proto': url.protocol.replace(':', ''), + }) + } - const hasBody = request.method !== 'GET' && request.method !== 'HEAD' - - return fetch(target.toString(), { - method: request.method, - headers, - body: hasBody ? request.body : undefined, - redirect: 'manual', - }) + return proxy(request, PAGES_ORIGIN) }, } diff --git a/server/cloudflare-site-bridge/wrangler.toml b/server/cloudflare-site-bridge/wrangler.toml index 9b9df39..103f431 100644 --- a/server/cloudflare-site-bridge/wrangler.toml +++ b/server/cloudflare-site-bridge/wrangler.toml @@ -18,3 +18,8 @@ compatibility_date = "2024-04-01" routes = [ { pattern = "d3ro.chanpaca.net/*", zone_name = "chanpaca.net" } ] + +# 웹앱(apps/web) 컨테이너를 노출한 Cloudflare Tunnel 호스트. 터널 공개 호스트를 추가한 뒤 채운다. +# 비어 있으면 /app/* 는 503 을 돌려준다(랜딩으로 떨어뜨리지 않는다). +[vars] +WEB_APP_ORIGIN = "" diff --git a/server/supabase/config.toml b/server/supabase/config.toml index b0817f8..dd066b7 100644 --- a/server/supabase/config.toml +++ b/server/supabase/config.toml @@ -45,6 +45,8 @@ additional_redirect_urls = [ "http://localhost:3000", "http://localhost:3001", "https://d3ro.chanpaca.net", + "https://d3ro.chanpaca.net/app/**", + "http://localhost:3000/app/**", "d3ro-voice://auth-callback" ] jwt_expiry = 3600