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

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