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:
parent
88f24d84a1
commit
b6fe588a7c
30 changed files with 493 additions and 95 deletions
|
|
@ -26,3 +26,7 @@ obj/
|
||||||
*.db-wal
|
*.db-wal
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# 앱별 로컬 환경 파일(비밀값)이 이미지 빌드 컨텍스트에 들어가지 않게 한다.
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import type {
|
||||||
LicenseInfo,
|
LicenseInfo,
|
||||||
} from '@d3ro/core/types'
|
} from '@d3ro/core/types'
|
||||||
import { Feature } 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 */
|
/** 업그레이드 유도 이벤트를 모든 렌더러에 broadcast */
|
||||||
function broadcastUpgradePrompt(event: UpgradePromptEvent): void {
|
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' }) => {
|
ipcMain.handle(IPC_CHANNELS.LICENSE.OPEN_BILLING, async (_event, params: { tier: 'pro' | 'pro_plus' }) => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = app.isPackaged ? 'https://d3ro.dev' : 'http://localhost:3000'
|
const url = billingUrl({ tier: params.tier })
|
||||||
const billingUrl = `${baseUrl}/billing?tier=${params.tier}`
|
await shell.openExternal(app.isPackaged ? url : url.replace(PUBLIC_SITE_ORIGIN, DEV_WEB_ORIGIN))
|
||||||
await shell.openExternal(billingUrl)
|
|
||||||
return ipcSuccess(undefined)
|
return ipcSuccess(undefined)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return ipcError(ErrorCode.UnknownError, `Failed to open billing: ${err}`)
|
return ipcError(ErrorCode.UnknownError, `Failed to open billing: ${err}`)
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@ import { ipcMain } from 'electron'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { ErrorCode, ipcError, ok, type IPCResult } from '@d3ro/core/errors'
|
import { ErrorCode, ipcError, ok, type IPCResult } from '@d3ro/core/errors'
|
||||||
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
|
import type { CheckoutSessionParams, CheckoutSessionResult } from '@d3ro/core/types'
|
||||||
|
import { billingUrl } from '@d3ro/core/web-urls'
|
||||||
import { getCloudSyncService } from '../services/CloudSyncService'
|
import { getCloudSyncService } from '../services/CloudSyncService'
|
||||||
|
|
||||||
const CHECKOUT_FUNCTION = 'stripe-checkout'
|
const CHECKOUT_FUNCTION = 'stripe-checkout'
|
||||||
const SUBSCRIPTION_FUNCTION = 'payple-manage'
|
const SUBSCRIPTION_FUNCTION = 'payple-manage'
|
||||||
const CHECKOUT_SUCCESS_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=success'
|
const CHECKOUT_SUCCESS_URL = billingUrl({ result: 'success' })
|
||||||
const CHECKOUT_CANCEL_URL = 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled'
|
const CHECKOUT_CANCEL_URL = billingUrl({ result: 'canceled' })
|
||||||
const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com'
|
const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com'
|
||||||
const STRIPE_CHECKOUT_PATH_PREFIX = '/c/pay/'
|
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
|
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
|
||||||
|
|
|
||||||
50
apps/desktop/tests/unit/license-open-billing.spec.ts
Normal file
50
apps/desktop/tests/unit/license-open-billing.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -85,8 +85,8 @@ describe('desktop payment IPC security boundary', () => {
|
||||||
expect(name).toBe('stripe-checkout')
|
expect(name).toBe('stripe-checkout')
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
tier: 'pro',
|
tier: 'pro',
|
||||||
success_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=success',
|
success_url: 'https://d3ro.chanpaca.net/app/billing?success=1',
|
||||||
cancel_url: 'https://d3ro.chanpaca.net/billing?desktop_checkout=cancelled',
|
cancel_url: 'https://d3ro.chanpaca.net/app/billing?canceled=1',
|
||||||
idempotency_key: expect.stringMatching(/^desktop:stripe-checkout:[0-9a-f-]{36}$/)
|
idempotency_key: expect.stringMatching(/^desktop:stripe-checkout:[0-9a-f-]{36}$/)
|
||||||
})
|
})
|
||||||
expect(options).toMatchObject({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
expect(options).toMatchObject({ timeoutMs: PAYMENT_REQUEST_TIMEOUT_MS })
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { RealtimeChannel } from '@supabase/supabase-js'
|
import type { RealtimeChannel } from '@supabase/supabase-js'
|
||||||
import type { Meeting, Team, TeamRole } from '@d3ro/api-client'
|
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'
|
import { supabase } from '../../lib/supabase'
|
||||||
|
|
||||||
export interface TeamSummary extends Team {
|
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 INVITE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32}$/
|
||||||
const TEAM_NAME_MAX_LENGTH = 80
|
const TEAM_NAME_MAX_LENGTH = 80
|
||||||
const EMAIL_MAX_LENGTH = 254
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
|
@ -181,7 +184,7 @@ export function normalizeInviteUrl(value: string): string {
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
parsed.origin !== TRUSTED_INVITE_ORIGIN
|
parsed.origin !== TRUSTED_INVITE_ORIGIN
|
||||||
|| (parsed.pathname !== '/accept-invite' && parsed.pathname !== '/accept-invite/')
|
|| (parsed.pathname !== INVITE_PATH && parsed.pathname !== `${INVITE_PATH}/`)
|
||||||
|| parsed.username !== ''
|
|| parsed.username !== ''
|
||||||
|| parsed.password !== ''
|
|| parsed.password !== ''
|
||||||
|| parsed.hash !== ''
|
|| parsed.hash !== ''
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { Linking, Platform } from 'react-native'
|
import { Linking, Platform } from 'react-native'
|
||||||
|
import { billingUrl } from '@d3ro/core/web-urls'
|
||||||
import {
|
import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
deepLinkToSubscriptions,
|
deepLinkToSubscriptions,
|
||||||
|
|
@ -541,7 +542,7 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await Linking.openURL('https://d3ro.chanpaca.net/billing')
|
await Linking.openURL(billingUrl())
|
||||||
}, [entitlement.snapshot.provider, entitlement.snapshot.storeProductId])
|
}, [entitlement.snapshot.provider, entitlement.snapshot.storeProductId])
|
||||||
|
|
||||||
const clearOperation = useCallback(() => {
|
const clearOperation = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
} from '../lib/mobile-ads-context'
|
} from '../lib/mobile-ads-context'
|
||||||
import { useMobilePreferences } from '../lib/preferences-context'
|
import { useMobilePreferences } from '../lib/preferences-context'
|
||||||
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
|
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
|
||||||
|
import { SITE_URLS } from '@d3ro/core/web-urls'
|
||||||
|
|
||||||
type Navigation = NativeStackNavigationProp<RootStackParamList, 'ProPaywall'>
|
type Navigation = NativeStackNavigationProp<RootStackParamList, 'ProPaywall'>
|
||||||
type TranslationKey = Parameters<ReturnType<typeof useI18n>['t']>[0]
|
type TranslationKey = Parameters<ReturnType<typeof useI18n>['t']>[0]
|
||||||
|
|
@ -305,7 +306,7 @@ export default function ProPaywallScreen(): React.ReactElement {
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
accessibilityLabel={t('mobile.paywall.terms')}
|
accessibilityLabel={t('mobile.paywall.terms')}
|
||||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/terms')}
|
onPress={() => void Linking.openURL(SITE_URLS.terms)}
|
||||||
style={styles.legalLink}
|
style={styles.legalLink}
|
||||||
>
|
>
|
||||||
<ThemeText variant="small" color="accent">{t('mobile.paywall.terms')}</ThemeText>
|
<ThemeText variant="small" color="accent">{t('mobile.paywall.terms')}</ThemeText>
|
||||||
|
|
@ -314,7 +315,7 @@ export default function ProPaywallScreen(): React.ReactElement {
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
accessibilityLabel={t('mobile.paywall.privacy')}
|
accessibilityLabel={t('mobile.paywall.privacy')}
|
||||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/privacy/')}
|
onPress={() => void Linking.openURL(SITE_URLS.privacy)}
|
||||||
style={styles.legalLink}
|
style={styles.legalLink}
|
||||||
>
|
>
|
||||||
<ThemeText variant="small" color="accent">{t('mobile.paywall.privacy')}</ThemeText>
|
<ThemeText variant="small" color="accent">{t('mobile.paywall.privacy')}</ThemeText>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
import { useI18n, type TranslationKey } from '@d3ro/i18n'
|
import { useI18n, type TranslationKey } from '@d3ro/i18n'
|
||||||
|
import { SITE_URLS } from '@d3ro/core/web-urls'
|
||||||
import { useAuth } from '../lib/auth-context'
|
import { useAuth } from '../lib/auth-context'
|
||||||
import { useEntitlement } from '../lib/entitlement-context'
|
import { useEntitlement } from '../lib/entitlement-context'
|
||||||
import {
|
import {
|
||||||
|
|
@ -475,21 +476,21 @@ export default function SettingsScreen(): React.ReactElement {
|
||||||
label={t('mobile.set.privacyPolicy')}
|
label={t('mobile.set.privacyPolicy')}
|
||||||
hint={t('mobile.set.privacyPolicyHint')}
|
hint={t('mobile.set.privacyPolicyHint')}
|
||||||
testID="settings-privacy-policy"
|
testID="settings-privacy-policy"
|
||||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/privacy/')}
|
onPress={() => void Linking.openURL(SITE_URLS.privacy)}
|
||||||
/>
|
/>
|
||||||
<LegalLinkRow
|
<LegalLinkRow
|
||||||
label={t('mobile.set.termsOfService')}
|
label={t('mobile.set.termsOfService')}
|
||||||
hint={t('mobile.set.termsOfServiceHint')}
|
hint={t('mobile.set.termsOfServiceHint')}
|
||||||
testID="settings-terms-of-service"
|
testID="settings-terms-of-service"
|
||||||
bordered
|
bordered
|
||||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/terms/')}
|
onPress={() => void Linking.openURL(SITE_URLS.terms)}
|
||||||
/>
|
/>
|
||||||
<LegalLinkRow
|
<LegalLinkRow
|
||||||
label={t('mobile.set.accountDeletion')}
|
label={t('mobile.set.accountDeletion')}
|
||||||
hint={t('mobile.set.accountDeletionHint')}
|
hint={t('mobile.set.accountDeletionHint')}
|
||||||
testID="settings-account-deletion-web"
|
testID="settings-account-deletion-web"
|
||||||
bordered
|
bordered
|
||||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/delete-account/')}
|
onPress={() => void Linking.openURL(SITE_URLS.deleteAccount)}
|
||||||
/>
|
/>
|
||||||
</ThemeCard>
|
</ThemeCard>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
d3roNativeFonts,
|
d3roNativeFonts,
|
||||||
} from '@d3ro/ui-native'
|
} from '@d3ro/ui-native'
|
||||||
import { useI18n } from '@d3ro/i18n'
|
import { useI18n } from '@d3ro/i18n'
|
||||||
|
import { SITE_URLS } from '@d3ro/core/web-urls'
|
||||||
import { isSupabaseConfigured, supabase } from '../lib/supabase'
|
import { isSupabaseConfigured, supabase } from '../lib/supabase'
|
||||||
import { AUTH_REDIRECT_URL } from '../lib/auth-redirect'
|
import { AUTH_REDIRECT_URL } from '../lib/auth-redirect'
|
||||||
import { getAuthCapabilities, type AuthCapabilities } from '../lib/auth-capabilities'
|
import { getAuthCapabilities, type AuthCapabilities } from '../lib/auth-capabilities'
|
||||||
|
|
@ -346,7 +347,7 @@ export default function SignUpScreen(): React.ReactElement {
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
accessibilityLabel={t('mobile.set.termsOfService')}
|
accessibilityLabel={t('mobile.set.termsOfService')}
|
||||||
testID="sign-up-terms-link"
|
testID="sign-up-terms-link"
|
||||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/terms/')}
|
onPress={() => void Linking.openURL(SITE_URLS.terms)}
|
||||||
style={styles.legalLink}
|
style={styles.legalLink}
|
||||||
disabled={interfaceBusy}
|
disabled={interfaceBusy}
|
||||||
>
|
>
|
||||||
|
|
@ -361,7 +362,7 @@ export default function SignUpScreen(): React.ReactElement {
|
||||||
accessibilityRole="link"
|
accessibilityRole="link"
|
||||||
accessibilityLabel={t('mobile.set.privacyPolicy')}
|
accessibilityLabel={t('mobile.set.privacyPolicy')}
|
||||||
testID="sign-up-privacy-link"
|
testID="sign-up-privacy-link"
|
||||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/privacy/')}
|
onPress={() => void Linking.openURL(SITE_URLS.privacy)}
|
||||||
style={styles.legalLink}
|
style={styles.legalLink}
|
||||||
disabled={interfaceBusy}
|
disabled={interfaceBusy}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
45
apps/web/Dockerfile
Normal file
45
apps/web/Dockerfile
Normal 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"]
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
|
||||||
# OAuth callback base URL
|
# 공개 주소는 @d3ro/core/web-urls 가 정본이다. 로컬 개발 주소는 http://localhost:3000/app (basePath).
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
# NEXT_PUBLIC_* 값은 빌드 시점에 번들에 들어간다. Docker 이미지는 --build-arg 로 넘긴다.
|
||||||
|
|
||||||
# Payple browser SDK client key (test keys start with test_)
|
# Payple browser SDK client key (test keys start with test_)
|
||||||
# Missing values fail closed and disable the Payple checkout button.
|
# Missing values fail closed and disable the Payple checkout button.
|
||||||
|
|
|
||||||
|
|
@ -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
26
apps/web/next.config.ts
Normal 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
|
||||||
|
|
@ -2,6 +2,7 @@ import Link from 'next/link'
|
||||||
import { Alert, Box, Button, Chip, Stack, Typography } from '@mui/material'
|
import { Alert, Box, Button, Chip, Stack, Typography } from '@mui/material'
|
||||||
import { Check } from 'lucide-react'
|
import { Check } from 'lucide-react'
|
||||||
import type { Subscription, SubscriptionTier } from '@d3ro/api-client'
|
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 { MetalCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||||
import { BillingCheckoutOptions } from '@/components/billing/billing-checkout-options'
|
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 {
|
function tierLabel(tier: SubscriptionTier): string {
|
||||||
return tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()
|
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 periodEnd = formatDate(subscription.current_period_end)
|
||||||
const stripeCanceled = params['canceled'] === '1'
|
const stripeCanceled = params['canceled'] === '1'
|
||||||
const stripeReturned = params['success'] === '1'
|
const stripeReturned = params['success'] === '1'
|
||||||
|
const selectedTier = selectedTierFrom(params['tier'])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 1120, mx: 'auto', p: { xs: 2.5, md: 4 }, pt: 4, pb: 10 }}>
|
<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}
|
currentTier={subscription.tier}
|
||||||
canPurchase={canPurchase}
|
canPurchase={canPurchase}
|
||||||
catalog={state.catalog}
|
catalog={state.catalog}
|
||||||
|
selectedTier={selectedTier}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -259,14 +267,19 @@ function PlanCard({
|
||||||
plan,
|
plan,
|
||||||
currentTier,
|
currentTier,
|
||||||
canPurchase,
|
canPurchase,
|
||||||
catalog
|
catalog,
|
||||||
|
selectedTier
|
||||||
}: {
|
}: {
|
||||||
plan: Plan
|
plan: Plan
|
||||||
currentTier: SubscriptionTier
|
currentTier: SubscriptionTier
|
||||||
canPurchase: boolean
|
canPurchase: boolean
|
||||||
catalog: BillingCatalog | null
|
catalog: BillingCatalog | null
|
||||||
|
selectedTier: PaidPlanTier | null
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const active = plan.tier === currentTier
|
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
|
const catalogPrices: BillingCatalogPrice[] = plan.tier === 'free' || !catalog
|
||||||
? []
|
? []
|
||||||
: catalog.plans[plan.tier]
|
: catalog.plans[plan.tier]
|
||||||
|
|
@ -274,21 +287,23 @@ function PlanCard({
|
||||||
return (
|
return (
|
||||||
<MetalCard
|
<MetalCard
|
||||||
data-testid={`billing-plan-${plan.tier}`}
|
data-testid={`billing-plan-${plan.tier}`}
|
||||||
|
data-selected={selected ? 'true' : undefined}
|
||||||
|
aria-current={selected ? 'true' : undefined}
|
||||||
sx={{
|
sx={{
|
||||||
p: 3,
|
p: 3,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
minHeight: 390,
|
minHeight: 390,
|
||||||
border: active
|
border: active || selected
|
||||||
? '2px solid var(--d3-accent-main)'
|
? '2px solid var(--d3-accent-main)'
|
||||||
: plan.highlight
|
: recommended
|
||||||
? '1px solid var(--d3-accent-glow)'
|
? '1px solid var(--d3-accent-glow)'
|
||||||
: undefined
|
: undefined
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: 11, color: active || selected ? 'var(--d3-accent-main)' : 'var(--d3-text-label)', mb: 0.75 }}>
|
||||||
{active ? 'CURRENT PLAN' : plan.highlight ? 'RECOMMENDED' : 'PLAN'}
|
{active ? 'CURRENT PLAN' : selected ? 'SELECTED PLAN' : recommended ? 'RECOMMENDED' : 'PLAN'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography component="h2" sx={{ fontSize: 24, fontWeight: 600, color: 'var(--d3-text-inverse)' }}>{plan.name}</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 }}>
|
<Typography sx={{ mt: 0.5, mb: 3, color: plan.tier === 'free' ? 'var(--d3-text-label)' : 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: 16 }}>
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,23 @@
|
||||||
// 공유 레이아웃 — auth 가드 + Sidebar
|
// 공유 레이아웃 — auth 가드 + Sidebar
|
||||||
// route group `(app)`은 URL에 영향을 주지 않고 하위 모든 라우트에 적용.
|
// route group `(app)`은 URL에 영향을 주지 않고 하위 모든 라우트에 적용.
|
||||||
|
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { Box } from '@mui/material'
|
import { Box } from '@mui/material'
|
||||||
import { Sidebar } from '@/components/layout/sidebar'
|
import { Sidebar } from '@/components/layout/sidebar'
|
||||||
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
|
import { getSupabaseServerClient, isSupabaseConfiguredServer } from '@/lib/supabase-server'
|
||||||
|
import { loginPath, RETURN_PATH_HEADER } from '@/lib/web-app-url'
|
||||||
|
|
||||||
export default async function AppLayout({
|
export default async function AppLayout({
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}): Promise<React.ReactElement> {
|
}): Promise<React.ReactElement> {
|
||||||
|
// 결제 진입(/billing?tier=…)처럼 proxy 가 경로를 넘긴 요청은 로그인 뒤 그 경로로 돌아온다.
|
||||||
|
const returnPath = (await headers()).get(RETURN_PATH_HEADER)
|
||||||
|
|
||||||
if (!isSupabaseConfiguredServer()) {
|
if (!isSupabaseConfiguredServer()) {
|
||||||
redirect('/login')
|
redirect(loginPath(returnPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = await getSupabaseServerClient()
|
const supabase = await getSupabaseServerClient()
|
||||||
|
|
@ -22,7 +27,7 @@ export default async function AppLayout({
|
||||||
} = await supabase.auth.getUser()
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect('/login')
|
redirect(loginPath(returnPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,40 @@
|
||||||
// apps/web/src/app/auth/callback/route.ts
|
// apps/web/src/app/auth/callback/route.ts
|
||||||
// OAuth 콜백 — provider에서 리다이렉트된 code를 session으로 교환.
|
// 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 { 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 code = searchParams.get('code')
|
||||||
const next = searchParams.get('next') ?? '/dashboard'
|
const next = safeReturnPath(searchParams.get('next'))
|
||||||
|
|
||||||
if (!isSupabaseConfiguredServer()) {
|
if (!isSupabaseConfiguredServer()) {
|
||||||
return NextResponse.redirect(`${origin}/login?error=supabase_not_configured`)
|
return NextResponse.redirect(appUrlFor(origin, '/login?error=supabase_not_configured'))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return NextResponse.redirect(`${origin}/login?error=no_code`)
|
return NextResponse.redirect(appUrlFor(origin, '/login?error=no_code'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = await getSupabaseServerClient()
|
const supabase = await getSupabaseServerClient()
|
||||||
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
||||||
|
|
||||||
if (error) {
|
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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ import { Box, Stack, Alert } from '@mui/material'
|
||||||
import { useI18n } from '@d3ro/i18n'
|
import { useI18n } from '@d3ro/i18n'
|
||||||
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
||||||
import { useAuth } from '@/components/providers/auth-provider'
|
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 {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -24,7 +30,7 @@ export default function LoginPage(): React.ReactElement {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && user) {
|
if (!loading && user) {
|
||||||
router.replace('/dashboard')
|
router.replace(returnPathFromLocation())
|
||||||
}
|
}
|
||||||
}, [loading, user, router])
|
}, [loading, user, router])
|
||||||
|
|
||||||
|
|
@ -38,7 +44,10 @@ export default function LoginPage(): React.ReactElement {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const supabase = getSupabaseBrowserClient()
|
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({
|
const { error: err } = await supabase.auth.signInWithOAuth({
|
||||||
provider: provider === 'apple' ? 'apple' : provider,
|
provider: provider === 'apple' ? 'apple' : provider,
|
||||||
options: { redirectTo }
|
options: { redirectTo }
|
||||||
|
|
@ -69,7 +78,7 @@ export default function LoginPage(): React.ReactElement {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
setSigningIn(false)
|
setSigningIn(false)
|
||||||
} else {
|
} else {
|
||||||
router.replace('/dashboard')
|
router.replace(returnPathFromLocation())
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||||
|
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||||
import type { PaypleTier } from './payple-client'
|
import type { PaypleTier } from './payple-client'
|
||||||
|
|
||||||
interface CheckoutButtonProps {
|
interface CheckoutButtonProps {
|
||||||
|
|
@ -46,8 +47,8 @@ export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElemen
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tier,
|
tier,
|
||||||
idempotency_key: `stripe-checkout:${crypto.randomUUID()}`,
|
idempotency_key: `stripe-checkout:${crypto.randomUUID()}`,
|
||||||
success_url: `${window.location.origin}/billing?success=1`,
|
success_url: browserBillingUrl('success'),
|
||||||
cancel_url: `${window.location.origin}/billing?canceled=1`
|
cancel_url: browserBillingUrl('canceled')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
||||||
import Script from 'next/script'
|
import Script from 'next/script'
|
||||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||||
|
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||||
import {
|
import {
|
||||||
assertSuccessfulCheckoutResponse,
|
assertSuccessfulCheckoutResponse,
|
||||||
clearPaypleIdempotencyKey,
|
clearPaypleIdempotencyKey,
|
||||||
|
|
@ -94,7 +95,7 @@ export function PaypleCheckoutButton({ tier, catalogPrice }: PaypleCheckoutButto
|
||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
tier,
|
tier,
|
||||||
catalogPrice,
|
catalogPrice,
|
||||||
resultUrl: `${window.location.origin}/billing`,
|
resultUrl: browserBillingUrl(),
|
||||||
openSdk: (request) => window.PaypleCpayAuthCheck(request),
|
openSdk: (request) => window.PaypleCpayAuthCheck(request),
|
||||||
chargeBillingKey: async (payerId, result) => {
|
chargeBillingKey: async (payerId, result) => {
|
||||||
const response = await fetch(checkoutEndpoint(), {
|
const response = await fetch(checkoutEndpoint(), {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useState } from 'react'
|
||||||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||||
|
import { browserBillingUrl } from '@/lib/web-app-url'
|
||||||
|
|
||||||
export function PortalButton(): React.ReactElement {
|
export function PortalButton(): React.ReactElement {
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
@ -39,7 +40,7 @@ export function PortalButton(): React.ReactElement {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
return_url: `${window.location.origin}/billing`
|
return_url: browserBillingUrl()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
45
apps/web/src/lib/web-app-url.ts
Normal file
45
apps/web/src/lib/web-app-url.ts
Normal 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
18
apps/web/src/proxy.ts
Normal 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']
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# D3RO Voice — Standalone NAS Docker Compose Specification
|
# 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:
|
services:
|
||||||
d3ro-api-server:
|
d3ro-api-server:
|
||||||
|
|
@ -23,12 +24,6 @@ services:
|
||||||
- TZ=${TZ:-Asia/Seoul}
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
volumes:
|
volumes:
|
||||||
- ${DATA_PATH:-./data}:/app/data
|
- ${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:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
|
|
@ -44,7 +39,6 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net}
|
|
||||||
- API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required}
|
- API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required}
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required}
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required}
|
||||||
- ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true}
|
- ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true}
|
||||||
|
|
@ -57,3 +51,25 @@ services:
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- NEXT_PUBLIC_API_URL=${PUBLIC_URL:-https://d3ro.chanpaca.net}
|
|
||||||
- API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required}
|
- API_SERVER_URL=${API_SERVER_URL:?API_SERVER_URL is required}
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required}
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET:?ADMIN_SESSION_SECRET is required}
|
||||||
- ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true}
|
- ADMIN_COOKIE_SECURE=${ADMIN_COOKIE_SECURE:-true}
|
||||||
|
|
@ -58,6 +57,35 @@ services:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
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)
|
# Optional: Local AI Engine on NAS (Ollama)
|
||||||
# Run with: docker compose --profile ai up -d
|
# Run with: docker compose --profile ai up -d
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ if (-not (Test-Path $nasPkgDir)) {
|
||||||
|
|
||||||
# 2. Build Docker Image
|
# 2. Build Docker Image
|
||||||
if (-not $SkipBuild) {
|
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
|
docker build -t d3ro-voice-api:latest ./apps/api-server
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "API Docker image build failed!"
|
Write-Error "API Docker image build failed!"
|
||||||
|
|
@ -73,6 +73,30 @@ if (-not $SkipBuild) {
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
Write-Host " Docker image built: d3ro-voice-admin:latest" -ForegroundColor Green
|
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 {
|
} else {
|
||||||
Write-Host "`n[1/4] Skipping Docker build (-SkipBuild specified)" -ForegroundColor Gray
|
Write-Host "`n[1/4] Skipping Docker build (-SkipBuild specified)" -ForegroundColor Gray
|
||||||
}
|
}
|
||||||
|
|
@ -80,10 +104,12 @@ if (-not $SkipBuild) {
|
||||||
# 3. Export Docker Image Archives
|
# 3. Export Docker Image Archives
|
||||||
$tarPath = Join-Path $nasPkgDir "d3ro-voice-api.tar"
|
$tarPath = Join-Path $nasPkgDir "d3ro-voice-api.tar"
|
||||||
$adminTarPath = Join-Path $nasPkgDir "d3ro-voice-admin.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
|
Write-Host "`n[2/4] Exporting Docker Images to Archives..." -ForegroundColor Yellow
|
||||||
docker save -o $tarPath d3ro-voice-api:latest
|
docker save -o $tarPath d3ro-voice-api:latest
|
||||||
docker save -o $adminTarPath d3ro-voice-admin: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
|
# 4. Prepare Standalone NAS Package
|
||||||
Write-Host "`n[3/4] Packaging NAS deployment files..." -ForegroundColor Yellow
|
Write-Host "`n[3/4] Packaging NAS deployment files..." -ForegroundColor Yellow
|
||||||
|
|
@ -102,7 +128,9 @@ $requiredDeploymentSecrets = @(
|
||||||
"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_SERVICE_ROLE_KEY"
|
||||||
)
|
)
|
||||||
$deploymentSecrets = @{}
|
$deploymentSecrets = @{}
|
||||||
foreach ($name in $requiredDeploymentSecrets) {
|
foreach ($name in $requiredDeploymentSecrets) {
|
||||||
|
|
@ -130,6 +158,8 @@ ADMIN_SESSION_SECRET=$($deploymentSecrets["ADMIN_SESSION_SECRET"])
|
||||||
API_SERVER_URL=$($deploymentSecrets["API_SERVER_URL"])
|
API_SERVER_URL=$($deploymentSecrets["API_SERVER_URL"])
|
||||||
CORS_ALLOWED_ORIGINS=$($deploymentSecrets["CORS_ALLOWED_ORIGINS"])
|
CORS_ALLOWED_ORIGINS=$($deploymentSecrets["CORS_ALLOWED_ORIGINS"])
|
||||||
ALLOWED_HOSTS=$($deploymentSecrets["ALLOWED_HOSTS"])
|
ALLOWED_HOSTS=$($deploymentSecrets["ALLOWED_HOSTS"])
|
||||||
|
SUPABASE_URL=$($deploymentSecrets["SUPABASE_URL"])
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=$($deploymentSecrets["SUPABASE_SERVICE_ROLE_KEY"])
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
"@
|
"@
|
||||||
$deploymentEnvPath = Join-Path $nasPkgDir ".env"
|
$deploymentEnvPath = Join-Path $nasPkgDir ".env"
|
||||||
|
|
@ -154,14 +184,16 @@ $readmeContent = @"
|
||||||
|
|
||||||
[Synology / QNAP / Linux NAS 배포 방법]
|
[Synology / QNAP / Linux NAS 배포 방법]
|
||||||
|
|
||||||
1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, docker-compose.yml, .env, nas-control.sh)을
|
1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, d3ro-voice-admin.tar, d3ro-voice-web.tar,
|
||||||
NAS의 Docker 작업 폴더 ($NasPath)에 업로드합니다.
|
docker-compose.yml, .env, nas-control.sh)을 NAS의 Docker 작업 폴더 ($NasPath)에 업로드합니다.
|
||||||
|
|
||||||
2. NAS SSH 터미널에 접속하여 해당 폴더로 이동합니다:
|
2. NAS SSH 터미널에 접속하여 해당 폴더로 이동합니다:
|
||||||
cd $NasPath
|
cd $NasPath
|
||||||
|
|
||||||
3. Docker 이미지를 로드합니다:
|
3. Docker 이미지를 로드합니다:
|
||||||
docker load < d3ro-voice-api.tar
|
docker load < d3ro-voice-api.tar
|
||||||
|
docker load < d3ro-voice-admin.tar
|
||||||
|
docker load < d3ro-voice-web.tar
|
||||||
|
|
||||||
4. 서비스를 시작합니다:
|
4. 서비스를 시작합니다:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
@ -171,6 +203,7 @@ $readmeContent = @"
|
||||||
- 관리자 백오피스: http://<NAS_IP>:$Port/admin
|
- 관리자 백오피스: http://<NAS_IP>:$Port/admin
|
||||||
- API Swagger: http://<NAS_IP>:$Port/swagger
|
- API Swagger: http://<NAS_IP>:$Port/swagger
|
||||||
- 헬스체크: http://<NAS_IP>:$Port/health
|
- 헬스체크: http://<NAS_IP>:$Port/health
|
||||||
|
- 웹앱: http://<NAS_IP>:3002/app/login (공개 주소 https://d3ro.chanpaca.net/app 는 사이트 브리지 워커 경유)
|
||||||
"@
|
"@
|
||||||
Set-Content -Path (Join-Path $nasPkgDir "README.txt") -Value $readmeContent -Encoding UTF8
|
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"
|
ssh -p $NasSshPort "$NasUser@$NasHost" "mkdir -p $NasPath/data"
|
||||||
|
|
||||||
Write-Host " [2/3] Uploading deployment package to NAS (SCP)..." -ForegroundColor Gray
|
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
|
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 "`n========================================================" -ForegroundColor Green
|
||||||
Write-Host " NAS Deployment Completed Successfully!" -ForegroundColor Green
|
Write-Host " NAS Deployment Completed Successfully!" -ForegroundColor Green
|
||||||
Write-Host " Access your D3RO Cloud Services at:" -ForegroundColor Cyan
|
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 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 " - Swagger API Docs: http://$($NasHost):$Port/swagger" -ForegroundColor White
|
||||||
Write-Host " - Health Check: http://$($NasHost):$Port/health" -ForegroundColor White
|
Write-Host " - Health Check: http://$($NasHost):$Port/health" -ForegroundColor White
|
||||||
Write-Host "========================================================" -ForegroundColor Green
|
Write-Host "========================================================" -ForegroundColor Green
|
||||||
|
|
@ -206,6 +239,8 @@ if ($DeploySsh -and $NasHost) {
|
||||||
Write-Host " Output Folder: $nasPkgDir" -ForegroundColor White
|
Write-Host " Output Folder: $nasPkgDir" -ForegroundColor White
|
||||||
Write-Host " Files generated:" -ForegroundColor White
|
Write-Host " Files generated:" -ForegroundColor White
|
||||||
Write-Host " - d3ro-voice-api.tar (Docker image archive)" -ForegroundColor Gray
|
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 " - docker-compose.yml (NAS Compose configuration)" -ForegroundColor Gray
|
||||||
Write-Host " - .env (Environment variables)" -ForegroundColor Gray
|
Write-Host " - .env (Environment variables)" -ForegroundColor Gray
|
||||||
Write-Host " - nas-control.sh (Container management script)" -ForegroundColor Gray
|
Write-Host " - nas-control.sh (Container management script)" -ForegroundColor Gray
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ set -e
|
||||||
: "${API_SERVER_URL:?API_SERVER_URL is required}"
|
: "${API_SERVER_URL:?API_SERVER_URL is required}"
|
||||||
: "${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required}"
|
: "${CORS_ALLOWED_ORIGINS:?CORS_ALLOWED_ORIGINS is required}"
|
||||||
: "${ALLOWED_HOSTS:?ALLOWED_HOSTS 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 \
|
for value in \
|
||||||
"$JWT_SECRET" \
|
"$JWT_SECRET" \
|
||||||
|
|
@ -25,7 +30,12 @@ for value in \
|
||||||
"$ADMIN_SESSION_SECRET" \
|
"$ADMIN_SESSION_SECRET" \
|
||||||
"$API_SERVER_URL" \
|
"$API_SERVER_URL" \
|
||||||
"$CORS_ALLOWED_ORIGINS" \
|
"$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
|
case "$value" in
|
||||||
*$'\n'*|*$'\r'*) echo "Deployment values must be single-line" >&2; exit 1 ;;
|
*$'\n'*|*$'\r'*) echo "Deployment values must be single-line" >&2; exit 1 ;;
|
||||||
esac
|
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"
|
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 "${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"
|
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
|
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}"
|
echo -e "\n${YELLOW}[3/4] Packaging NAS deployment files...${NC}"
|
||||||
cp "$ROOT_DIR/docker-compose.nas.yml" "$OUT_DIR/docker-compose.yml"
|
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
|
API_SERVER_URL=$API_SERVER_URL
|
||||||
CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS
|
CORS_ALLOWED_ORIGINS=$CORS_ALLOWED_ORIGINS
|
||||||
ALLOWED_HOSTS=$ALLOWED_HOSTS
|
ALLOWED_HOSTS=$ALLOWED_HOSTS
|
||||||
|
SUPABASE_URL=$SUPABASE_URL
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$OUT_DIR/.env"
|
chmod 600 "$OUT_DIR/.env"
|
||||||
|
|
@ -94,14 +125,16 @@ cat <<EOF > "$OUT_DIR/README.txt"
|
||||||
|
|
||||||
[Synology / QNAP / Linux NAS 배포 방법]
|
[Synology / QNAP / Linux NAS 배포 방법]
|
||||||
|
|
||||||
1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, docker-compose.yml, .env, nas-control.sh)을
|
1. 이 폴더의 모든 파일 (d3ro-voice-api.tar, d3ro-voice-admin.tar, d3ro-voice-web.tar,
|
||||||
NAS의 Docker 작업 폴더 (예: /volume1/docker/d3ro)에 업로드합니다.
|
docker-compose.yml, .env, nas-control.sh)을 NAS의 Docker 작업 폴더 (예: /volume1/docker/d3ro)에 업로드합니다.
|
||||||
|
|
||||||
2. NAS SSH 터미널에 접속하여 해당 폴더로 이동합니다:
|
2. NAS SSH 터미널에 접속하여 해당 폴더로 이동합니다:
|
||||||
cd /volume1/docker/d3ro
|
cd /volume1/docker/d3ro
|
||||||
|
|
||||||
3. Docker 이미지를 로드합니다:
|
3. Docker 이미지를 로드합니다:
|
||||||
docker load < d3ro-voice-api.tar
|
docker load < d3ro-voice-api.tar
|
||||||
|
docker load < d3ro-voice-admin.tar
|
||||||
|
docker load < d3ro-voice-web.tar
|
||||||
|
|
||||||
4. 서비스를 시작합니다:
|
4. 서비스를 시작합니다:
|
||||||
./nas-control.sh start (또는 docker compose up -d)
|
./nas-control.sh start (또는 docker compose up -d)
|
||||||
|
|
@ -111,6 +144,7 @@ cat <<EOF > "$OUT_DIR/README.txt"
|
||||||
- 관리자 백오피스: http://<NAS_IP>:$PORT/admin
|
- 관리자 백오피스: http://<NAS_IP>:$PORT/admin
|
||||||
- API Swagger: http://<NAS_IP>:$PORT/swagger
|
- API Swagger: http://<NAS_IP>:$PORT/swagger
|
||||||
- 헬스체크: http://<NAS_IP>:$PORT/health
|
- 헬스체크: http://<NAS_IP>:$PORT/health
|
||||||
|
- 웹앱: http://<NAS_IP>:3002/app/login (공개 주소 https://d3ro.chanpaca.net/app 는 사이트 브리지 워커 경유)
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo -e "${GREEN}NAS Deployment Package ready at: $OUT_DIR${NC}"
|
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
|
if [ -n "$NAS_HOST" ]; then
|
||||||
echo -e "\n${YELLOW}[4/4] Deploying to Remote NAS ($NAS_USER@$NAS_HOST:$NAS_PATH)...${NC}"
|
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"
|
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/"
|
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 compose down 2>/dev/null || true; docker compose up -d"
|
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 "\n${GREEN}========================================================${NC}"
|
||||||
echo -e "${GREEN} NAS Deployment Completed Successfully!${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 " - Admin: http://$NAS_HOST:$PORT/admin"
|
||||||
echo -e " - Swagger: http://$NAS_HOST:$PORT/swagger"
|
echo -e " - Swagger: http://$NAS_HOST:$PORT/swagger"
|
||||||
echo -e " - Health: http://$NAS_HOST:$PORT/health"
|
echo -e " - Health: http://$NAS_HOST:$PORT/health"
|
||||||
|
echo -e " - Web App: http://$NAS_HOST:3002/app/login"
|
||||||
echo -e "${GREEN}========================================================${NC}"
|
echo -e "${GREEN}========================================================${NC}"
|
||||||
else
|
else
|
||||||
echo -e "\n${GREEN}========================================================${NC}"
|
echo -e "\n${GREEN}========================================================${NC}"
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,75 @@
|
||||||
// server/cloudflare-site-bridge/src/index.ts
|
// 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 커스텀 도메인은 존 DNS에 CNAME을 요구하므로, DNS를 건드릴 수 없는 동안
|
||||||
// 이 워커가 도메인을 살린다. 콘텐츠 정본은 Pages 배포본 하나이므로 CI가 Pages에
|
// 이 워커가 도메인을 살린다.
|
||||||
// 배포하면 도메인에도 그대로 반영된다.
|
|
||||||
|
import { WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls'
|
||||||
|
|
||||||
const PAGES_ORIGIN = 'https://d3ro.pages.dev'
|
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<string, string> = {}): Promise<Response> {
|
||||||
|
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 {
|
export default {
|
||||||
async fetch(request: Request): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
const target = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
target.protocol = 'https:'
|
|
||||||
target.hostname = new URL(PAGES_ORIGIN).hostname
|
|
||||||
target.port = ''
|
|
||||||
|
|
||||||
const headers = new Headers(request.headers)
|
if (isWebAppPath(url.pathname)) {
|
||||||
headers.delete('host')
|
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 proxy(request, PAGES_ORIGIN)
|
||||||
|
|
||||||
return fetch(target.toString(), {
|
|
||||||
method: request.method,
|
|
||||||
headers,
|
|
||||||
body: hasBody ? request.body : undefined,
|
|
||||||
redirect: 'manual',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,8 @@ compatibility_date = "2024-04-01"
|
||||||
routes = [
|
routes = [
|
||||||
{ pattern = "d3ro.chanpaca.net/*", zone_name = "chanpaca.net" }
|
{ pattern = "d3ro.chanpaca.net/*", zone_name = "chanpaca.net" }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 웹앱(apps/web) 컨테이너를 노출한 Cloudflare Tunnel 호스트. 터널 공개 호스트를 추가한 뒤 채운다.
|
||||||
|
# 비어 있으면 /app/* 는 503 을 돌려준다(랜딩으로 떨어뜨리지 않는다).
|
||||||
|
[vars]
|
||||||
|
WEB_APP_ORIGIN = ""
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ additional_redirect_urls = [
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://localhost:3001",
|
"http://localhost:3001",
|
||||||
"https://d3ro.chanpaca.net",
|
"https://d3ro.chanpaca.net",
|
||||||
|
"https://d3ro.chanpaca.net/app/**",
|
||||||
|
"http://localhost:3000/app/**",
|
||||||
"d3ro-voice://auth-callback"
|
"d3ro-voice://auth-callback"
|
||||||
]
|
]
|
||||||
jwt_expiry = 3600
|
jwt_expiry = 3600
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue