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.
This commit is contained in:
Yun Chan 2026-09-26 15:48:30 +09:00
parent 88f24d84a1
commit b6fe588a7c
30 changed files with 493 additions and 95 deletions

View file

@ -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}`)

View file

@ -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

View file

@ -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<string, CapturedHandler>()
async function openBilling(tier: 'pro' | 'pro_plus'): Promise<unknown> {
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')
})
})

View file

@ -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 })

View file

@ -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<string, unknown> {
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 !== ''

View file

@ -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(() => {

View file

@ -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<RootStackParamList, 'ProPaywall'>
type TranslationKey = Parameters<ReturnType<typeof useI18n>['t']>[0]
@ -305,7 +306,7 @@ export default function ProPaywallScreen(): React.ReactElement {
<Pressable
accessibilityRole="link"
accessibilityLabel={t('mobile.paywall.terms')}
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/terms')}
onPress={() => void Linking.openURL(SITE_URLS.terms)}
style={styles.legalLink}
>
<ThemeText variant="small" color="accent">{t('mobile.paywall.terms')}</ThemeText>
@ -314,7 +315,7 @@ export default function ProPaywallScreen(): React.ReactElement {
<Pressable
accessibilityRole="link"
accessibilityLabel={t('mobile.paywall.privacy')}
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/privacy/')}
onPress={() => void Linking.openURL(SITE_URLS.privacy)}
style={styles.legalLink}
>
<ThemeText variant="small" color="accent">{t('mobile.paywall.privacy')}</ThemeText>

View file

@ -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)}
/>
<LegalLinkRow
label={t('mobile.set.termsOfService')}
hint={t('mobile.set.termsOfServiceHint')}
testID="settings-terms-of-service"
bordered
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/terms/')}
onPress={() => void Linking.openURL(SITE_URLS.terms)}
/>
<LegalLinkRow
label={t('mobile.set.accountDeletion')}
hint={t('mobile.set.accountDeletionHint')}
testID="settings-account-deletion-web"
bordered
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/delete-account/')}
onPress={() => void Linking.openURL(SITE_URLS.deleteAccount)}
/>
</ThemeCard>

View file

@ -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}
>

45
apps/web/Dockerfile Normal file
View file

@ -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"]

View file

@ -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.

View file

@ -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

26
apps/web/next.config.ts Normal file
View file

@ -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

View file

@ -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 (
<Box sx={{ maxWidth: 1120, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
@ -244,6 +251,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps): P
currentTier={subscription.tier}
canPurchase={canPurchase}
catalog={state.catalog}
selectedTier={selectedTier}
/>
))}
</Box>
@ -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 (
<MetalCard
data-testid={`billing-plan-${plan.tier}`}
data-selected={selected ? 'true' : undefined}
aria-current={selected ? 'true' : undefined}
sx={{
p: 3,
display: 'flex',
flexDirection: 'column',
minHeight: 390,
border: active
border: active || selected
? '2px solid var(--d3-accent-main)'
: plan.highlight
: recommended
? '1px solid var(--d3-accent-glow)'
: undefined
}}
>
<Box sx={{ flex: 1 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active || selected ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
{active ? 'CURRENT PLAN' : selected ? 'SELECTED PLAN' : recommended ? 'RECOMMENDED' : 'PLAN'}
</Typography>
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>{plan.name}</Typography>
<Typography sx={{ mt: 0.5, mb: 3, color: plan.tier === 'free' ? 'var(--d3-text-label)' : 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 16 }}>

View file

@ -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<React.ReactElement> {
// 결제 진입(/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 (

View file

@ -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<NextResponse> {
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<NextResponse> {
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))
}

View file

@ -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')

View file

@ -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')
})
}
)

View file

@ -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(), {

View file

@ -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()
})
}
)

View file

@ -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)}`
}

18
apps/web/src/proxy.ts Normal file
View file

@ -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']
}