| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- import { NextResponse } from 'next/server'
- import type { NextRequest } from 'next/server'
- import { isCrossSiteWrite } from '@/lib/same-origin'
- import { REQUEST_PATH_HEADER } from '@/lib/request-path'
- /**
- * Cross-origin access to the technician API, in development only.
- *
- * The app that consumes this API is native, and native clients are not subject
- * to the same-origin policy, so production needs no CORS headers at all and
- * deliberately sends none. The only thing they would enable there is a browser
- * on someone else's origin holding a workshop's bearer token.
- *
- * Development is different: Expo serves the app from localhost on its own port
- * while the workshop runs on another, so every request is cross-origin and the
- * browser blocks it before the app sees a response. From inside the app that
- * is indistinguishable from being offline, which sends people hunting for the
- * wrong bug.
- *
- * This is `proxy.ts`, not `middleware.ts`. Next 16 renamed the file, and on
- * 16.3.1 the old name still loads and runs but makes every matched API route
- * answer 404, whatever the handler returns. That failure looks like a missing
- * route rather than a misplaced file, so it is worth stating here: do not
- * rename this back.
- */
- const IS_DEV = process.env.NODE_ENV !== 'production'
- function applyCors(response: NextResponse, origin: string | null) {
- if (!IS_DEV) return response
- // Echoes the caller's origin rather than sending `*`, because a wildcard and
- // an `Authorization` header are not a combination worth normalising, even
- // in development.
- response.headers.set('Access-Control-Allow-Origin', origin ?? '*')
- response.headers.append('Vary', 'Origin')
- response.headers.set('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS')
- response.headers.set('Access-Control-Allow-Headers', 'Authorization,Content-Type,Accept,X-Org-Id')
- response.headers.set('Access-Control-Max-Age', '600')
- // Without this the browser hides the header from the app entirely, and
- // sign-in succeeds on the server while the client reports no token back.
- // Native clients see every header and never needed it.
- response.headers.set('Access-Control-Expose-Headers', 'set-auth-token')
- return response
- }
- const PROTECTED_API = '/api/protected/'
- /**
- * A page request carries the path it asked for, so a layout that sends a
- * visitor to sign-in can bring them back there (lib/sign-in-redirect.server.ts).
- * A layout has no other way to see its URL. Next's own `_rsc` marker is left
- * out, and anything a client sent under the same name is overwritten.
- */
- function withRequestPath(request: NextRequest) {
- const url = request.nextUrl.clone()
- url.searchParams.delete('_rsc')
- const requestHeaders = new Headers(request.headers)
- requestHeaders.set(REQUEST_PATH_HEADER, `${url.pathname}${url.search}`)
- return NextResponse.next({ request: { headers: requestHeaders } })
- }
- export default function proxy(request: NextRequest) {
- if (!request.nextUrl.pathname.startsWith('/api/')) return withRequestPath(request)
- // A write to the cookie-authenticated API from another site is refused
- // before any handler runs. See lib/same-origin.ts.
- if (request.nextUrl.pathname.startsWith(PROTECTED_API)) {
- if (isCrossSiteWrite({ method: request.method, header: (n) => request.headers.get(n) })) {
- return NextResponse.json({ error: 'Cross-site request refused' }, { status: 403 })
- }
- return undefined
- }
- if (!IS_DEV) return undefined
- const origin = request.headers.get('origin')
- // Preflight. The app sends Authorization and X-Org-Id, both of which put
- // every request past the "simple request" bar and into a preflight.
- if (request.method === 'OPTIONS') {
- return applyCors(new NextResponse(null, { status: 204 }), origin)
- }
- return applyCors(NextResponse.next(), origin)
- }
- export const config = {
- matcher: [
- // Everything under the technician API except the WebSocket. An upgrade
- // handshake is not an ordinary response, and putting CORS headers on one
- // is at best pointless and at worst breaks it.
- '/api/v1/tech/((?!ws$).*)',
- // Sign-in lives here, so the app hits it before it has a session and
- // would otherwise fail its very first request from a browser.
- '/api/public/auth/:path*',
- // The cookie-authenticated API: cross-site writes are refused here.
- '/api/protected/:path*',
- // Pages, for the requested path above. Not the API, Next's own files, or
- // anything with a file extension (icons, images, the manifest).
- '/((?!api/|_next/|.*\\.[^/]+$).*)',
- ],
- }
|