Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
addComponentsDir,
addImports,
addPluginTemplate,
addServerHandler,
addTemplate,
createResolver,
defineNuxtModule,
Expand Down Expand Up @@ -78,6 +79,22 @@ export interface ModuleOptions {
*/
integrity?: boolean | 'sha256' | 'sha384' | 'sha512'
}
/**
* Google Static Maps proxy configuration.
* Proxies static map images through your server to fix CORS issues and enable caching.
*/
googleStaticMapsProxy?: {
/**
* Enable proxying Google Static Maps through your own origin.
* @default false
*/
enabled?: boolean
/**
* Cache duration for static map images in seconds.
* @default 3600 (1 hour)
*/
cacheMaxAge?: number
}
/**
* Whether the module is enabled.
*
Expand Down Expand Up @@ -115,6 +132,10 @@ export default defineNuxtModule<ModuleOptions>({
timeout: 15_000, // Configures the maximum time (in milliseconds) allowed for each fetch attempt.
},
},
googleStaticMapsProxy: {
enabled: false,
cacheMaxAge: 3600,
},
enabled: true,
debug: false,
},
Expand All @@ -136,11 +157,21 @@ export default defineNuxtModule<ModuleOptions>({
if (unheadVersion?.startsWith('1')) {
logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`)
}
nuxt.options.runtimeConfig['nuxt-scripts'] = { version }
nuxt.options.runtimeConfig['nuxt-scripts'] = {
version,
// Private proxy config with API key (server-side only)
googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled
? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey }
: undefined,
}
nuxt.options.runtimeConfig.public['nuxt-scripts'] = {
// expose for devtools
version: nuxt.options.dev ? version : undefined,
defaultScriptOptions: config.defaultScriptOptions,
// Only expose enabled and cacheMaxAge to client, not apiKey
googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled
? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge }
: undefined,
}

// Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution
Expand Down Expand Up @@ -250,6 +281,14 @@ export default defineNuxtModule<ModuleOptions>({
})
})

// Add Google Static Maps proxy handler if enabled
if (config.googleStaticMapsProxy?.enabled) {
addServerHandler({
route: '/_scripts/google-static-maps-proxy',
handler: await resolvePath('./runtime/server/google-static-maps-proxy'),
})
}

if (nuxt.options.dev)
setupDevToolsUI(config, resolvePath)
},
Expand Down
59 changes: 51 additions & 8 deletions src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { withQuery } from 'ufo'
import type { QueryObject } from 'ufo'
import { defu } from 'defu'
import { hash } from 'ohash'
import { useHead } from 'nuxt/app'
import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app'
import type { ElementScriptTrigger } from '#nuxt-scripts/types'
import { scriptRuntimeConfig } from '#nuxt-scripts/utils'
import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement'
Expand Down Expand Up @@ -104,6 +104,17 @@ const props = withDefaults(defineProps<{
* Extra Markers to add to the map.
*/
markers?: (`${string},${string}` | google.maps.marker.AdvancedMarkerElementOptions)[]
/**
* Map IDs for light and dark color modes.
* When provided, the map will automatically switch styles based on color mode.
* Requires @nuxtjs/color-mode or manual colorMode prop.
*/
mapIds?: { light?: string, dark?: string }
/**
* Manual color mode control. When provided, overrides auto-detection from @nuxtjs/color-mode.
* Accepts 'light', 'dark', or a reactive ref.
*/
colorMode?: 'light' | 'dark'
}>(), {
// @ts-expect-error untyped
trigger: ['mouseenter', 'mouseover', 'mousedown'],
Expand All @@ -119,6 +130,26 @@ const emits = defineEmits<{
}>()

const apiKey = props.apiKey || scriptRuntimeConfig('googleMaps')?.apiKey
const runtimeConfig = useRuntimeConfig()
const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy

// Color mode support - try to auto-detect from @nuxtjs/color-mode
const nuxtApp = tryUseNuxtApp()
const nuxtColorMode = nuxtApp?.$colorMode as { value: string } | undefined

const currentColorMode = computed(() => {
if (props.colorMode)
return props.colorMode
if (nuxtColorMode?.value)
return nuxtColorMode.value === 'dark' ? 'dark' : 'light'
return 'light'
})

const currentMapId = computed(() => {
if (!props.mapIds)
return props.mapOptions?.mapId
return props.mapIds[currentColorMode.value] || props.mapIds.light || props.mapOptions?.mapId
})

const mapsApi = ref<typeof google.maps | undefined>()

Expand All @@ -144,10 +175,10 @@ const { load, status, onLoaded } = useScriptGoogleMaps({
})

const options = computed(() => {
return defu({ center: centerOverride.value }, props.mapOptions, {
const mapId = props.mapOptions?.styles ? undefined : (currentMapId.value || 'map')
return defu({ center: centerOverride.value, mapId }, props.mapOptions, {
center: props.center,
zoom: 15,
mapId: props.mapOptions?.styles ? undefined : 'map',
})
})
const ready = ref(false)
Expand Down Expand Up @@ -266,8 +297,13 @@ function importLibrary<T>(key: string): Promise<T> {
}
}, { immediate: true })
})
libraries.set(key, p)
return p as any as Promise<T>
// Clear cache on failure to allow retry
const cached = Promise.resolve(p).catch((err) => {
libraries.delete(key)
throw err
})
libraries.set(key, cached)
return cached as Promise<T>
}

const googleMaps = {
Expand Down Expand Up @@ -380,7 +416,7 @@ onMounted(() => {
})
})

if (import.meta.server) {
if (import.meta.server && !proxyConfig?.enabled) {
useHead({
link: [
{
Expand Down Expand Up @@ -419,9 +455,11 @@ const placeholder = computed(() => {
center,
}, {
size: `${props.width}x${props.height}`,
key: apiKey,
// Only include API key if not using proxy (proxy injects it server-side)
key: proxyConfig?.enabled ? undefined : apiKey,
scale: 2, // we assume a high DPI to avoid hydration issues
style: props.mapOptions?.styles ? transformMapStyles(props.mapOptions.styles) : undefined,
map_id: currentMapId.value,
markers: [
...(props.markers || []),
props.centerMarker && center,
Expand All @@ -438,7 +476,12 @@ const placeholder = computed(() => {
})
.join('|'),
})
return withQuery('https://maps.googleapis.com/maps/api/staticmap', placeholderOptions as QueryObject)

const baseUrl = proxyConfig?.enabled
? '/_scripts/google-static-maps-proxy'
: 'https://maps.googleapis.com/maps/api/staticmap'

return withQuery(baseUrl, placeholderOptions as QueryObject)
})

const placeholderAttrs = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,28 @@
</template>

<script lang="ts">
import type { MarkerClusterer, MarkerClustererOptions } from '@googlemaps/markerclusterer'
import { inject, onUnmounted, provide, shallowRef, type InjectionKey, type ShallowRef } from 'vue'
import { whenever } from '@vueuse/core'
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'

// Inline types to avoid requiring @googlemaps/markerclusterer as a build-time dependency
export interface MarkerClustererInstance {
render: () => void
setMap: (map: google.maps.Map | null) => void
addListener: (event: string, handler: () => void) => void
addMarker: (marker: google.maps.marker.AdvancedMarkerElement | google.maps.Marker, noDraw?: boolean) => void
removeMarker: (marker: google.maps.marker.AdvancedMarkerElement | google.maps.Marker, noDraw?: boolean) => boolean
}

export interface MarkerClustererOptions {
markers?: google.maps.marker.AdvancedMarkerElement[]
algorithm?: unknown
renderer?: unknown
onClusterClick?: unknown
}

export const MARKER_CLUSTERER_INJECTION_KEY = Symbol('marker-clusterer') as InjectionKey<{
markerClusterer: ShallowRef<MarkerClusterer | undefined>
markerClusterer: ShallowRef<MarkerClustererInstance | undefined>
requestRerender: () => void
}>
</script>
Expand All @@ -26,19 +41,19 @@ const markerClustererEvents = [
] as const

const emit = defineEmits<{
(event: typeof markerClustererEvents[number], payload: MarkerClusterer): void
(event: typeof markerClustererEvents[number], payload: MarkerClustererInstance): void
}>()

const mapContext = inject(MAP_INJECTION_KEY, undefined)

const markerClusterer = shallowRef<MarkerClusterer | undefined>(undefined)
const markerClusterer = shallowRef<MarkerClustererInstance | undefined>(undefined)

whenever(() => mapContext?.map.value, async (map) => {
const { MarkerClusterer } = await import('@googlemaps/markerclusterer')
markerClusterer.value = new MarkerClusterer({
map,
...props.options,
})
} as any) as MarkerClustererInstance

setupMarkerClustererEventListeners(markerClusterer.value)
}, {
Expand Down Expand Up @@ -76,9 +91,9 @@ provide(
},
)

function setupMarkerClustererEventListeners(markerClusterer: MarkerClusterer) {
function setupMarkerClustererEventListeners(clusterer: MarkerClustererInstance) {
markerClustererEvents.forEach((event) => {
markerClusterer.addListener(event, () => emit(event, markerClusterer))
clusterer.addListener(event, () => emit(event, clusterer))
})
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
</template>

<script setup lang="ts">
import { inject, shallowRef } from 'vue'
import { inject, onUnmounted, shallowRef } from 'vue'
import { whenever } from '@vueuse/core'
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'
import { ADVANCED_MARKER_ELEMENT_INJECTION_KEY } from './ScriptGoogleMapsAdvancedMarkerElement.vue'
Expand Down Expand Up @@ -42,4 +42,12 @@ whenever(
once: true,
},
)

onUnmounted(() => {
if (advancedMarkerElementContext?.advancedMarkerElement.value && pinElement.value) {
// Clear the content from the parent marker
advancedMarkerElementContext.advancedMarkerElement.value.content = null
}
pinElement.value = undefined
})
</script>
67 changes: 67 additions & 0 deletions src/runtime/server/google-static-maps-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3'
import { $fetch } from 'ofetch'
import { withQuery } from 'ufo'
import { useRuntimeConfig } from '#imports'

export default defineEventHandler(async (event) => {
const runtimeConfig = useRuntimeConfig()
const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy
const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleStaticMapsProxy

if (!publicConfig?.enabled) {
throw createError({
statusCode: 404,
statusMessage: 'Google Static Maps proxy is not enabled',
})
}

// Get API key from private config (server-side only, not exposed to client)
const apiKey = privateConfig?.apiKey
if (!apiKey) {
throw createError({
statusCode: 500,
statusMessage: 'Google Maps API key not configured for proxy',
})
}

// Validate referer to prevent external abuse
const referer = getHeader(event, 'referer')
const host = getHeader(event, 'host')
if (referer && host) {
const refererUrl = new URL(referer).host
if (refererUrl !== host) {
throw createError({
statusCode: 403,
statusMessage: 'Invalid referer',
Comment on lines +31 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const refererUrl = new URL(referer).host
if (refererUrl !== host) {
throw createError({
statusCode: 403,
statusMessage: 'Invalid referer',
try {
const refererUrl = new URL(referer).host
if (refererUrl !== host) {
throw createError({
statusCode: 403,
statusMessage: 'Invalid referer',
})
}
} catch (error) {
// Re-throw Nuxt errors as-is
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error
}
// Handle URL parsing errors
throw createError({
statusCode: 400,
statusMessage: 'Invalid referer URL',

The referer header URL parsing can throw an uncaught error if the referer is malformed, causing an unhandled exception instead of a proper HTTP error response.

View Details

Analysis

Unhandled URL parsing error in Google Static Maps proxy referer validation

What fails: The google-static-maps-proxy.ts event handler crashes with an unhandled TypeError when the referer header contains a malformed URL.

How to reproduce:

# Send a request with a malformed referer header
curl -H "Referer: this-is-not-a-valid-url" http://localhost:3000/api/google-static-maps

Result: Returns 500 Internal Server Error due to unhandled exception from new URL() constructor throwing TypeError: Invalid URL

Expected: Should return 400 Bad Request with proper error message, consistent with other validation errors in the function that use createError() for HTTP error responses

Details: The referer header can contain any string value from the HTTP request. When new URL(referer) is called without try-catch wrapping (line 31), it throws a TypeError for malformed URLs. According to JavaScript best practices, the new URL() constructor should be wrapped in error handling when parsing untrusted input.

})
}
}

const query = getQuery(event)

// Remove any client-provided key and use server-side key
const { key: _clientKey, ...safeQuery } = query

const googleMapsUrl = withQuery('https://maps.googleapis.com/maps/api/staticmap', {
...safeQuery,
key: apiKey,
})

const response = await $fetch.raw(googleMapsUrl, {
headers: {
'User-Agent': 'Nuxt Scripts Google Static Maps Proxy',
},
}).catch((error: any) => {
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'Failed to fetch static map',
})
})

const cacheMaxAge = publicConfig.cacheMaxAge || 3600
setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/png')
setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`)
setHeader(event, 'Vary', 'Accept-Encoding')

return response._data
})
Loading