proxy.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import { NextResponse } from 'next/server'
  2. import { UNA_URL, UNA_API_KEY, MULTITENANT, PREVIEW_DOMAIN, PREVIEW_API_KEY, joinUnaUrl } from 'app/config';
  3. import { getDomainByHostname } from './lib/domains/domains';
  4. import { dropForeignClientHeaders, resolveEdge } from './lib/client-headers';
  5. export const config = {
  6. // Skip /.well-known/* so Digital Asset Links / AASA hit App Router routes
  7. // directly (no 404, no UNA rewrite).
  8. matcher: ["/((?!sw.js|logo192.png|loader.svg|favicon.ico|favicon.svg|manifest.json|static/|lucide/|_vercel|\\.well-known).*)"],
  9. //runtime: 'experimental-edge',
  10. };
  11. // The CDN or proxy in front of NEO, whose address and location headers UNA may read.
  12. const EDGE = resolveEdge(process.env);
  13. const TENANT_HEADERS = ['x-tenant-id', 'x-tenant-una-url', 'x-tenant-una-key', 'x-tenant-revision'];
  14. /**
  15. * Request headers for a Next route (page, icon API).
  16. * - Client-sent `x-tenant-*` are always dropped: the page trusts them to pick
  17. * the UNA host and key, so a client must never be able to set them.
  18. * - The resolved tenant is added here and passed with
  19. * `rewrite(url, { request: { headers } })` — it reaches the route only.
  20. * Response headers would also be sent to the browser (tenant API key).
  21. */
  22. function routeRequestHeaders(request, tenant) {
  23. const headers = new Headers(request.headers);
  24. for (const name of TENANT_HEADERS) headers.delete(name);
  25. if (tenant) {
  26. headers.set('x-tenant-id', String(tenant.id));
  27. headers.set('x-tenant-una-url', tenant.unaUrl);
  28. headers.set('x-tenant-una-key', tenant.unaApiKey);
  29. headers.set('x-tenant-revision', String(tenant.revision));
  30. }
  31. return headers;
  32. }
  33. async function resolveTenant(hostnameWithPort) {
  34. const hostname = hostnameWithPort.split(':')[0];
  35. const domain = await getDomainByHostname(hostname);
  36. return domain;
  37. }
  38. /**
  39. * CI previews (UNA's .github/workflows/ci.yml): `pr-<n>.<PREVIEW_DOMAIN>` is a client
  40. * alias for PR <n>, whose backend is `api-pr-<n>.<PREVIEW_DOMAIN>`. Returned in the
  41. * tenant shape so the proxy and the page use it the same way. Null for any other host,
  42. * which keeps the configured UNA_URL.
  43. */
  44. function resolvePreviewBackend(hostnameWithPort) {
  45. if (!PREVIEW_DOMAIN || !PREVIEW_API_KEY) return null;
  46. const hostname = String(hostnameWithPort || '').split(':')[0].toLowerCase();
  47. const match = hostname.match(/^pr-(\d+)\.(.+)$/);
  48. if (!match || match[2] !== PREVIEW_DOMAIN.toLowerCase()) return null;
  49. return {
  50. id: `preview-${match[1]}`,
  51. unaUrl: `https://api-pr-${match[1]}.${PREVIEW_DOMAIN}`,
  52. unaApiKey: PREVIEW_API_KEY,
  53. revision: 0,
  54. };
  55. }
  56. function cookieHeaderFromRequest(request) {
  57. let cookieString = '';
  58. request.cookies.getAll().forEach((item) => {
  59. cookieString += item.name + '=' + encodeURIComponent(item.value) + '; ';
  60. });
  61. return cookieString;
  62. }
  63. function sysAiChatPath(pathname) {
  64. const match = String(pathname || '').match(/^\/sys-ai-chat\/([^/]+)\/?$/);
  65. if (!match?.[1]) return null;
  66. return `/sys-ai-chat/${match[1]}`;
  67. }
  68. /** Same-origin proxy to UNA (PHP APIs and sys-ai-chat SSE), forwarding session cookies. */
  69. function rewriteToUna(request, tenant, unaPath) {
  70. let unaUrl = UNA_URL;
  71. let unaApiKey = UNA_API_KEY;
  72. if (MULTITENANT && tenant == null) {
  73. return new NextResponse('Tenant not found', { status: 404 });
  74. }
  75. if (tenant) {
  76. unaUrl = tenant.unaUrl;
  77. unaApiKey = tenant.unaApiKey;
  78. }
  79. // UNA trusts the visitor's address and location headers on requests with the API key:
  80. // pass only those the edge set, not ones the browser added.
  81. const tmpHeaders = dropForeignClientHeaders(new Headers(request.headers), EDGE);
  82. tmpHeaders.set('authorization', `Bearer ${unaApiKey}`);
  83. tmpHeaders.set('cookie', cookieHeaderFromRequest(request));
  84. const unaTarget = joinUnaUrl(unaPath, unaUrl) + new URL(request.url).search;
  85. return NextResponse.rewrite(unaTarget, {
  86. request: {
  87. headers: tmpHeaders,
  88. },
  89. });
  90. }
  91. export async function proxy(request) {
  92. // Main middleware entry: decide how to rewrite incoming requests.
  93. const pathname = request.nextUrl.pathname;
  94. // Early return for static files and source maps - return 404 immediately without rendering
  95. const staticExtensions = ['.map'];
  96. const isSourceMap = staticExtensions.some(ext => pathname.endsWith(ext));
  97. const isWellKnown = pathname.startsWith('/.well-known/');
  98. // Android App Links + iOS Universal Links verification files must be publicly
  99. // reachable (no redirects, application/json). Do not 404 these paths.
  100. const isDigitalAssetLinks =
  101. pathname === '/.well-known/assetlinks.json' ||
  102. pathname === '/.well-known/apple-app-site-association' ||
  103. pathname === '/apple-app-site-association';
  104. if (isDigitalAssetLinks) {
  105. const res = NextResponse.next();
  106. res.headers.set('Content-Type', 'application/json');
  107. return res;
  108. }
  109. if (isSourceMap || isWellKnown) {
  110. // Return 404 directly without any processing or rendering
  111. return new NextResponse(null, { status: 404 });
  112. }
  113. // serve images proxy as it is
  114. if (pathname.includes('/api/image')) {
  115. return NextResponse.next();
  116. }
  117. const hostname = request.headers.get("host");
  118. const tenant = MULTITENANT ? await resolveTenant(hostname) : resolvePreviewBackend(hostname);
  119. // Pretty URL for UNA Agents chat (GET hydrate JSON + POST AG-UI SSE).
  120. // Not a .php path, so it would otherwise hit the Next catch-all page.
  121. const aiChatPath = sysAiChatPath(pathname);
  122. if (aiChatPath) {
  123. return rewriteToUna(request, tenant, aiChatPath);
  124. }
  125. // If the request is NOT targeting a PHP endpoint, handle it as a regular
  126. // page or an internal asset (icons) served by this Next.js app.
  127. if (!request.nextUrl.pathname.includes('.php')) {
  128. if (tenant == null && MULTITENANT && !request.nextUrl.pathname.includes('static')) {
  129. // rewrite to specific page where new tenant can be created
  130. // return NextResponse.rewrite(new URL(`/new-tenant`, request.url));
  131. return NextResponse.redirect(new URL('http://localhost:4000'), 307); // temporary redirect
  132. }
  133. // A normal page. The page reads the viewer's cookies from its own request
  134. // (`headers()`), so they are never copied into the URL.
  135. if (!request.nextUrl.pathname.includes('.icon')) {
  136. // Map root URL `/` to `/home` for downstream routing.
  137. const pathname = request.nextUrl.pathname == '/' ? '/home' : request.nextUrl.pathname;
  138. const url = request.nextUrl.origin + pathname + request.nextUrl.search;
  139. const response = NextResponse.rewrite(new URL(url), {
  140. request: { headers: routeRequestHeaders(request, tenant) },
  141. });
  142. if (request.cookies.getAll().length === 0) {
  143. // Guests see the same page: let the CDN cache it.
  144. response.headers.set('Cache-Control', 'public, s-maxage=1')
  145. response.headers.set('CDN-Cache-Control', 'public, s-maxage=60')
  146. response.headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=3600')
  147. }
  148. return response
  149. }
  150. else{
  151. // Path contains `.icon` — rewrite to internal `/api/icon` route,
  152. // preserving the original query string.
  153. const url = new URL(request.url);
  154. const iconApiUrl = new URL('/api/icon', request.url);
  155. iconApiUrl.search = url.search;
  156. return NextResponse.rewrite(iconApiUrl, {
  157. request: { headers: routeRequestHeaders(request, tenant) },
  158. });
  159. }
  160. }
  161. else {
  162. const url = new URL(request.url);
  163. // Join without creating `//api.php` — UNA nginx 308s double-slash URLs,
  164. // and that relative Location breaks browser fetch through the proxy.
  165. // Use joinUnaUrl so tenant unaUrl and default UNA_URL share the same rules.
  166. const unaPath = request.nextUrl.pathname.replace('/api/', '/');
  167. // Stripe Embedded Checkout return_url: browser lands on this API URL and
  168. // would otherwise see raw JSON. Serve a tiny HTML bridge that re-fetches
  169. // the same URL as XHR (rewrite below) and redirects to data.url.
  170. const apiMethod = url.searchParams.get('r') || '';
  171. const isCheckoutReturn =
  172. apiMethod.includes('initialize_checkout_api') &&
  173. url.searchParams.has('session_id');
  174. const fetchMode = request.headers.get('sec-fetch-mode') || '';
  175. const isXhr =
  176. request.headers.get('x-neo-checkout-return') === '1' ||
  177. fetchMode === 'cors' ||
  178. fetchMode === 'same-origin';
  179. if (isCheckoutReturn && !isXhr) {
  180. const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Redirecting…</title></head><body>
  181. <script>
  182. (async function () {
  183. try {
  184. var res = await fetch(location.pathname + location.search, {
  185. credentials: 'same-origin',
  186. headers: {
  187. 'Accept': 'application/json',
  188. 'X-Neo-Checkout-Return': '1'
  189. },
  190. cache: 'no-store'
  191. });
  192. var payload = await res.json();
  193. var next = payload && payload.data && payload.data.url;
  194. if (typeof next === 'string' && next.length) {
  195. location.replace(next);
  196. return;
  197. }
  198. document.body.textContent = JSON.stringify(payload);
  199. } catch (e) {
  200. document.body.textContent = String(e && e.message ? e.message : e);
  201. }
  202. })();
  203. </script>
  204. <noscript>JavaScript is required to complete checkout.</noscript>
  205. </body></html>`;
  206. return new NextResponse(html, {
  207. status: 200,
  208. headers: {
  209. 'content-type': 'text/html; charset=utf-8',
  210. 'cache-control': 'no-store',
  211. },
  212. });
  213. }
  214. // Rewrite the request to the UNA backend URL, replacing a local
  215. // `/api/` prefix with the UNA path and forwarding the modified headers.
  216. return rewriteToUna(request, tenant, unaPath);
  217. }
  218. }