This commit is contained in:
Caleb Clavin 2026-10-07 18:25:12 -05:00 committed by GitHub
commit fa07d4e134
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 85 additions and 0 deletions

View File

@ -0,0 +1,41 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
// origin: ['*'] in config/cors.ts never matches a real Origin header
// (@adonisjs/cors only wildcards on the bare string origin: '*'), and a
// global fix would also expose the write API since credentials: true is
// set. Scope anonymous, read-only CORS to map tiles/assets and the one
// JSON route another app actually reads, GET/HEAD/OPTIONS only.
const MAP_ASSET_EXACT_PATHS = ['/api/maps/styles']
const MAP_ASSET_PREFIXES = ['/pmtiles/', '/basemaps-assets/', '/storage/maps/']
const READABLE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
export function isMapAssetPath(pathname: string): boolean {
return MAP_ASSET_EXACT_PATHS.includes(pathname) || MAP_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))
}
export function isReadableMethod(method: string): boolean {
return READABLE_METHODS.has(method)
}
export default class MapsCorsMiddleware {
async handle({ request, response }: HttpContext, next: NextFn) {
if (!isMapAssetPath(request.url()) || !isReadableMethod(request.method())) return next()
// safeHeader so this stays inert if the global CORS config is ever fixed.
response.safeHeader('Access-Control-Allow-Origin', '*')
response.header('Access-Control-Expose-Headers', 'ETag, Content-Range, Accept-Ranges, Content-Length')
response.header('Vary', 'Origin')
if (request.method() === 'OPTIONS') {
// serve-static only handles GET/HEAD, so OPTIONS would otherwise 404.
response.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS')
response.header('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Match')
response.header('Access-Control-Max-Age', '600')
return response.status(204)
}
return next()
}
}

View File

@ -24,6 +24,9 @@ server.errorHandler(() => import('#exceptions/handler'))
*/
server.use([
() => import('#middleware/container_bindings_middleware'),
// Must precede @adonisjs/cors: it ends unmatched-origin preflights (#endPreFlight)
// before this middleware would ever see them, since origin: ['*'] never matches.
() => import('#middleware/maps_cors_middleware'),
() => import('@adonisjs/cors/cors_middleware'),
() => import('@adonisjs/vite/vite_middleware'),
() => import('@adonisjs/inertia/inertia_middleware'),

View File

@ -0,0 +1,41 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { isMapAssetPath, isReadableMethod } from '../../app/middleware/maps_cors_middleware.js'
test('isMapAssetPath accepts the styles route, pmtiles, and basemaps-assets paths', () => {
for (const path of [
'/api/maps/styles',
'/pmtiles/washington.pmtiles',
'/basemaps-assets/fonts/Noto%20Sans%20Regular/0-255.pbf',
'/basemaps-assets/sprites/v4/light.json',
'/storage/maps/pmtiles/washington.pmtiles',
]) {
assert.equal(isMapAssetPath(path), true, `${path} should be a map asset path`)
}
})
test('isMapAssetPath rejects non-map paths and other /api/maps/ routes', () => {
for (const path of [
'/api/system/info',
'/api/maps',
'/api/mapsfoo',
'/api/maps/regions',
'/api/maps/download-remote',
'/maps',
'/',
'/pmtiles',
'/basemaps-assets',
]) {
assert.equal(isMapAssetPath(path), false, `${path} should not be a map asset path`)
}
})
test('isReadableMethod accepts GET, HEAD, and OPTIONS, rejects write methods', () => {
for (const method of ['GET', 'HEAD', 'OPTIONS']) {
assert.equal(isReadableMethod(method), true, `${method} should be readable`)
}
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
assert.equal(isReadableMethod(method), false, `${method} should not be readable`)
}
})