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

@ -1,29 +1,75 @@
// server/cloudflare-site-bridge/src/index.ts
// d3ro.chanpaca.net → Cloudflare Pages(d3ro.pages.dev) 프록시.
// d3ro.chanpaca.net 한 도메인 아래 두 앱을 잇는 브리지.
//
// /app, /app/* → 웹앱(apps/web, NAS 컨테이너) — WEB_APP_ORIGIN(터널 호스트)
// 그 밖 → 랜딩 사이트(site/, Cloudflare Pages)
//
// 경로 규칙의 정본은 packages/core/src/web-urls.ts 의 WEB_APP_BASE_PATH 다.
// Pages 커스텀 도메인은 존 DNS에 CNAME을 요구하므로, DNS를 건드릴 수 없는 동안
// 이 워커가 도메인을 살린다. 콘텐츠 정본은 Pages 배포본 하나이므로 CI가 Pages에
// 배포하면 도메인에도 그대로 반영된다.
// 이 워커가 도메인을 살린다.
import { WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls'
const PAGES_ORIGIN = 'https://d3ro.pages.dev'
interface Env {
/** 웹앱 컨테이너를 노출한 터널 호스트(예: https://d3ro-app.chanpaca.net). 비어 있으면 /app 은 503. */
WEB_APP_ORIGIN?: string
}
function isWebAppPath(pathname: string): boolean {
return pathname === WEB_APP_BASE_PATH || pathname.startsWith(`${WEB_APP_BASE_PATH}/`)
}
async function proxy(request: Request, origin: string, extraHeaders: Record<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 {
async fetch(request: Request): Promise<Response> {
const target = new URL(request.url)
target.protocol = 'https:'
target.hostname = new URL(PAGES_ORIGIN).hostname
target.port = ''
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const headers = new Headers(request.headers)
headers.delete('host')
if (isWebAppPath(url.pathname)) {
if (!env.WEB_APP_ORIGIN) {
return new Response('Web app is not available yet.', {
status: 503,
headers: { 'content-type': 'text/plain; charset=utf-8', 'retry-after': '3600' },
})
}
return proxy(request, env.WEB_APP_ORIGIN, {
'x-forwarded-host': url.host,
'x-forwarded-proto': url.protocol.replace(':', ''),
})
}
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
return fetch(target.toString(), {
method: request.method,
headers,
body: hasBody ? request.body : undefined,
redirect: 'manual',
})
return proxy(request, PAGES_ORIGIN)
},
}

View file

@ -18,3 +18,8 @@ compatibility_date = "2024-04-01"
routes = [
{ pattern = "d3ro.chanpaca.net/*", zone_name = "chanpaca.net" }
]
# 웹앱(apps/web) 컨테이너를 노출한 Cloudflare Tunnel 호스트. 터널 공개 호스트를 추가한 뒤 채운다.
# 비어 있으면 /app/* 는 503 을 돌려준다(랜딩으로 떨어뜨리지 않는다).
[vars]
WEB_APP_ORIGIN = ""