fix(edge): route /app and /api on d3ro.chanpaca.net through the NAS tunnel again
Since 2026-09-19 the site bridge worker served every path from Pages, so the API server on the NAS had no public route: admin login (API_SERVER_URL) and stt-proxy (D3RO_API_URL) called https://d3ro.chanpaca.net/api/... and got the landing page's 405. - The worker passes /app, /api and /health through to the domain origin (the kd-nas tunnel) and serves everything else from Pages. - The kd-nas tunnel sends path ^/app on d3ro.chanpaca.net to the web app (NAS 3002); other paths keep going to the API (NAS 5050). No extra hostname or DNS record is needed, so WEB_APP_ORIGIN is removed. - API_PATH_PREFIXES joins WEB_APP_BASE_PATH in packages/core/src/web-urls.ts (contract regenerated). Verified live: /app/login 200, /health and /api/health 200, API login 401 for an unknown account (was 405), landing/legal/404 unchanged, git/sso/admin hosts unaffected.
This commit is contained in:
parent
e87ce63440
commit
4aadee8264
4 changed files with 42 additions and 64 deletions
|
|
@ -1,10 +1,13 @@
|
||||||
// packages/core/src/web-urls.ts
|
// packages/core/src/web-urls.ts
|
||||||
// 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다.
|
// 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다.
|
||||||
//
|
//
|
||||||
// 한 도메인 아래 두 앱이 있다.
|
// 한 도메인 아래 세 서비스가 있다.
|
||||||
// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락
|
// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락
|
||||||
// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드
|
// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드
|
||||||
// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app 요청을 웹앱으로 보낸다.
|
// /api, /health API 서버(apps/api-server) — 관리자 인증, STT 중계
|
||||||
|
// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app·/api 요청을 도메인 원본
|
||||||
|
// (Cloudflare Tunnel kd-nas → NAS)으로 흘려보내고, 나머지는 Pages로 보낸다.
|
||||||
|
// 터널 ingress: d3ro.chanpaca.net path ^/app → NAS 3002, 그 밖 → NAS 5050.
|
||||||
// Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다.
|
// Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다.
|
||||||
|
|
||||||
import type { PaidPlanTier } from './plan-catalog'
|
import type { PaidPlanTier } from './plan-catalog'
|
||||||
|
|
@ -16,6 +19,9 @@ export const WEB_APP_BASE_PATH = '/app'
|
||||||
|
|
||||||
export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}`
|
export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}`
|
||||||
|
|
||||||
|
/** API 서버(apps/api-server)가 받는 경로. 관리자 로그인·stt-proxy가 이 도메인으로 부른다. */
|
||||||
|
export const API_PATH_PREFIXES = ['/api', '/health'] as const
|
||||||
|
|
||||||
export const SITE_URLS = {
|
export const SITE_URLS = {
|
||||||
home: `${PUBLIC_SITE_ORIGIN}/`,
|
home: `${PUBLIC_SITE_ORIGIN}/`,
|
||||||
download: `${PUBLIC_SITE_ORIGIN}/#download`,
|
download: `${PUBLIC_SITE_ORIGIN}/#download`,
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,46 @@
|
||||||
// server/cloudflare-site-bridge/src/index.ts
|
// server/cloudflare-site-bridge/src/index.ts
|
||||||
// d3ro.chanpaca.net 한 도메인 아래 두 앱을 잇는 브리지.
|
// d3ro.chanpaca.net 한 도메인 아래 세 서비스를 잇는 브리지.
|
||||||
//
|
//
|
||||||
// /app, /app/* → 웹앱(apps/web, NAS 컨테이너) — WEB_APP_ORIGIN(터널 호스트)
|
// /app, /app/* → 도메인 원본(Cloudflare Tunnel kd-nas) → NAS 웹앱(3002)
|
||||||
|
// /api/*, /health → 도메인 원본(Cloudflare Tunnel kd-nas) → NAS API 서버(5050)
|
||||||
// 그 밖 → 랜딩 사이트(site/, Cloudflare Pages)
|
// 그 밖 → 랜딩 사이트(site/, Cloudflare Pages)
|
||||||
//
|
//
|
||||||
// 경로 규칙의 정본은 packages/core/src/web-urls.ts 의 WEB_APP_BASE_PATH 다.
|
// 경로 규칙의 정본은 packages/core/src/web-urls.ts 다. 터널 ingress가 path로 웹앱과 API를 나눈다.
|
||||||
// Pages 커스텀 도메인은 존 DNS에 CNAME을 요구하므로, DNS를 건드릴 수 없는 동안
|
// 워커 안에서 같은 요청을 그대로 fetch 하면 워커를 다시 거치지 않고 도메인 원본(터널)으로 간다.
|
||||||
// 이 워커가 도메인을 살린다.
|
// Pages 커스텀 도메인은 존 DNS에 CNAME을 요구해 d3ro 레코드(터널)와 충돌하므로, 이 워커가
|
||||||
|
// 랜딩을 Pages에서 가져온다.
|
||||||
|
|
||||||
import { WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls'
|
import { API_PATH_PREFIXES, WEB_APP_BASE_PATH } from '../../../packages/core/src/web-urls'
|
||||||
|
|
||||||
const PAGES_ORIGIN = 'https://d3ro.pages.dev'
|
const PAGES_ORIGIN = 'https://d3ro.pages.dev'
|
||||||
|
const ORIGIN_PATH_PREFIXES: readonly string[] = [WEB_APP_BASE_PATH, ...API_PATH_PREFIXES]
|
||||||
|
|
||||||
interface Env {
|
function servedByOrigin(pathname: string): boolean {
|
||||||
/** 웹앱 컨테이너를 노출한 터널 호스트(예: https://d3ro-app.chanpaca.net). 비어 있으면 /app 은 503. */
|
return ORIGIN_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
|
||||||
WEB_APP_ORIGIN?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWebAppPath(pathname: string): boolean {
|
async function fromPages(request: Request): Promise<Response> {
|
||||||
return pathname === WEB_APP_BASE_PATH || pathname.startsWith(`${WEB_APP_BASE_PATH}/`)
|
const target = new URL(request.url)
|
||||||
}
|
target.protocol = 'https:'
|
||||||
|
target.hostname = new URL(PAGES_ORIGIN).hostname
|
||||||
async function proxy(request: Request, origin: string, extraHeaders: Record<string, string> = {}): Promise<Response> {
|
target.port = ''
|
||||||
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)
|
const headers = new Headers(request.headers)
|
||||||
headers.delete('host')
|
headers.delete('host')
|
||||||
for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value)
|
|
||||||
|
|
||||||
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
|
const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
|
||||||
const response = await fetch(target.toString(), {
|
return fetch(target.toString(), {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
headers,
|
headers,
|
||||||
body: hasBody ? request.body : undefined,
|
body: hasBody ? request.body : undefined,
|
||||||
redirect: 'manual',
|
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, env: Env): Promise<Response> {
|
async fetch(request: Request): Promise<Response> {
|
||||||
const url = new URL(request.url)
|
const { pathname } = new URL(request.url)
|
||||||
|
if (servedByOrigin(pathname)) return fetch(request)
|
||||||
if (isWebAppPath(url.pathname)) {
|
return fromPages(request)
|
||||||
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(':', ''),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return proxy(request, PAGES_ORIGIN)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,3 @@ 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 = ""
|
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,13 @@ export const PLAN_QUOTA: Readonly<Record<PlanQuotaTier, Readonly<Record<PlanQuot
|
||||||
// packages/core/src/web-urls.ts
|
// packages/core/src/web-urls.ts
|
||||||
// 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다.
|
// 공개 웹 주소 SSOT — 결제·법률·다운로드·초대 링크는 모두 여기서 만든다.
|
||||||
//
|
//
|
||||||
// 한 도메인 아래 두 앱이 있다.
|
// 한 도메인 아래 세 서비스가 있다.
|
||||||
// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락
|
// / 랜딩 사이트(site/, Cloudflare Pages) — 다운로드, 법률 문서, 초대 수락
|
||||||
// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드
|
// /app/... 웹앱(apps/web, Next basePath '/app') — 로그인, 결제, 대시보드
|
||||||
// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app 요청을 웹앱으로 보낸다.
|
// /api, /health API 서버(apps/api-server) — 관리자 인증, STT 중계
|
||||||
|
// 사이트 브리지 워커(server/cloudflare-site-bridge)가 /app·/api 요청을 도메인 원본
|
||||||
|
// (Cloudflare Tunnel kd-nas → NAS)으로 흘려보내고, 나머지는 Pages로 보낸다.
|
||||||
|
// 터널 ingress: d3ro.chanpaca.net path ^/app → NAS 3002, 그 밖 → NAS 5050.
|
||||||
// Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다.
|
// Deno 쪽 사본은 plan-catalog.ts 와 같은 방식으로 생성된다. 이 파일도 순수 값만 둔다.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -107,6 +110,9 @@ export const WEB_APP_BASE_PATH = '/app'
|
||||||
|
|
||||||
export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}`
|
export const WEB_APP_URL = `${PUBLIC_SITE_ORIGIN}${WEB_APP_BASE_PATH}`
|
||||||
|
|
||||||
|
/** API 서버(apps/api-server)가 받는 경로. 관리자 로그인·stt-proxy가 이 도메인으로 부른다. */
|
||||||
|
export const API_PATH_PREFIXES = ['/api', '/health'] as const
|
||||||
|
|
||||||
export const SITE_URLS = {
|
export const SITE_URLS = {
|
||||||
home: `${PUBLIC_SITE_ORIGIN}/`,
|
home: `${PUBLIC_SITE_ORIGIN}/`,
|
||||||
download: `${PUBLIC_SITE_ORIGIN}/#download`,
|
download: `${PUBLIC_SITE_ORIGIN}/#download`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue