-
Notifications
You must be signed in to change notification settings - Fork 0
Add fullscreen 3D Map page with geolocation + IP fallback #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,26 +1,49 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { NextApiRequest, NextApiResponse } from 'next' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type Data = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| latitude: number, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| latitude: number | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| longitude: number | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type ApiResponse = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| loc: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| loc?: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const fallbackLocation: Data = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| latitude: 40.7128, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| longitude: -74.006, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function getClientIp(req: NextApiRequest) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const forwardedFor = req.headers['x-forwarded-for'] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const forwardedIp = Array.isArray(forwardedFor) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ? forwardedFor[0] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| : forwardedFor?.split(',')[0] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cloudflareIp = req.headers['cf-connecting-ip'] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const headerIp = Array.isArray(cloudflareIp) ? cloudflareIp[0] : cloudflareIp | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return headerIp || forwardedIp || req.socket.remoteAddress || '' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default async function handler( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| req: NextApiRequest, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res: NextApiResponse<Data> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res: NextApiResponse<Data>, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const clientIP = req.headers['cf-connecting-ip'] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ipResponse = await fetch(`https://ipinfo.io/${clientIP}/json?token=ef8461623ce04a`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const clientIp = getClientIp(req) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ipResponse = await fetch( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `https://ipinfo.io/${clientIp}/json?token=ef8461623ce04a`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The IPInfo API token is hardcoded in the fetch URL. Hardcoding API keys or tokens in source code is a security risk and makes it difficult to rotate keys. Please move this token to an environment variable (e.g.,
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const apiResponse: ApiResponse = await ipResponse.json() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+33
to
36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add error handling for fetch failures. Network errors or API downtime will crash the handler without proper error handling.1
Suggested change
Footnotes
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [lat, long] = apiResponse.loc.split(",") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!apiResponse.loc) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.status(200).json(fallbackLocation) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [lat, long] = apiResponse.loc.split(',') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const data: Data = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| latitude: parseFloat(lat), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| longitude: parseFloat(long) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| longitude: parseFloat(long), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| res.status(200).json(data) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
28
to
49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add error handling for JSON parsing and API response errors. Without a try-catch block, fetch failures, network errors, and JSON parsing errors will cause unhandled promise rejections and crash the API handler.1
Suggested change
Footnotes
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,281 @@ | ||
| import Head from 'next/head' | ||
| import Script from 'next/script' | ||
| import { useEffect, useRef, useState } from 'react' | ||
| import { MapPin } from 'lucide-react' | ||
|
|
||
| import Page from '@/components/page' | ||
|
|
||
| type Coordinates = { | ||
| latitude: number | ||
| longitude: number | ||
| } | ||
|
|
||
| type LocationSource = 'browser' | 'ip' | 'default' | ||
|
|
||
| type LocationState = { | ||
| coordinates: Coordinates | ||
| source: LocationSource | ||
| label: string | ||
| } | ||
|
|
||
| type MapLibre = { | ||
| Map: new (options: Record<string, unknown>) => MapInstance | ||
| Marker: new (options?: Record<string, unknown>) => MapMarker | ||
| NavigationControl: new (options?: Record<string, unknown>) => unknown | ||
| GeolocateControl: new (options?: Record<string, unknown>) => unknown | ||
| FullscreenControl: new (options?: Record<string, unknown>) => unknown | ||
| } | ||
|
|
||
| type MapInstance = { | ||
| addControl: (control: unknown, position?: string) => void | ||
| flyTo: (options: Record<string, unknown>) => void | ||
| remove: () => void | ||
| } | ||
|
|
||
| type MapMarker = { | ||
| setLngLat: (coordinates: [number, number]) => MapMarker | ||
| addTo: (map: MapInstance) => MapMarker | ||
| remove: () => void | ||
| } | ||
|
|
||
| declare global { | ||
| interface Window { | ||
| maplibregl?: MapLibre | ||
| } | ||
| } | ||
|
|
||
| const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty' | ||
| const DEFAULT_LOCATION: LocationState = { | ||
| coordinates: { | ||
| latitude: 40.7128, | ||
| longitude: -74.006, | ||
| }, | ||
| source: 'default', | ||
| label: 'default location', | ||
| } | ||
|
|
||
| const browserLocationOptions: PositionOptions = { | ||
| enableHighAccuracy: true, | ||
| timeout: 10000, | ||
| maximumAge: 60000, | ||
| } | ||
|
|
||
| function getBrowserLocation() { | ||
| return new Promise<LocationState>((resolve, reject) => { | ||
| if (!navigator.geolocation) { | ||
| reject(new Error('Browser geolocation is not available')) | ||
| return | ||
| } | ||
|
|
||
| navigator.geolocation.getCurrentPosition( | ||
| (position) => { | ||
| resolve({ | ||
| coordinates: { | ||
| latitude: position.coords.latitude, | ||
| longitude: position.coords.longitude, | ||
| }, | ||
| source: 'browser', | ||
| label: 'browser location', | ||
| }) | ||
| }, | ||
| reject, | ||
| browserLocationOptions, | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| async function getIpLocation() { | ||
| const response = await fetch('/api/ip-lookup') | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error('IP location lookup failed') | ||
| } | ||
|
|
||
| const coordinates: Coordinates = await response.json() | ||
|
|
||
| if ( | ||
| !Number.isFinite(coordinates.latitude) || | ||
| !Number.isFinite(coordinates.longitude) | ||
| ) { | ||
| throw new Error('IP location lookup returned invalid coordinates') | ||
| } | ||
|
|
||
| return { | ||
| coordinates, | ||
| source: 'ip' as const, | ||
| label: 'IP address location', | ||
| } | ||
| } | ||
|
|
||
| const FullScreen3DMap = () => { | ||
| const mapContainerRef = useRef<HTMLDivElement | null>(null) | ||
| const mapRef = useRef<MapInstance | null>(null) | ||
| const markerRef = useRef<MapMarker | null>(null) | ||
| const [isMapLibreReady, setIsMapLibreReady] = useState(false) | ||
| const [location, setLocation] = useState<LocationState | null>(null) | ||
| const [status, setStatus] = useState('Requesting browser location…') | ||
|
|
||
| useEffect(() => { | ||
| let cancelled = false | ||
|
|
||
| const loadLocation = async () => { | ||
| try { | ||
| const browserLocation = await getBrowserLocation() | ||
|
|
||
| if (!cancelled) { | ||
| setLocation(browserLocation) | ||
| setStatus('Centered on your browser location') | ||
| } | ||
| } catch { | ||
| if (!cancelled) { | ||
| setStatus('Browser location unavailable. Falling back to IP address…') | ||
| } | ||
|
|
||
| try { | ||
| const ipLocation = await getIpLocation() | ||
|
|
||
| if (!cancelled) { | ||
| setLocation(ipLocation) | ||
| setStatus('Centered on your IP address location') | ||
| } | ||
| } catch { | ||
| if (!cancelled) { | ||
| setLocation(DEFAULT_LOCATION) | ||
| setStatus('Location lookup failed. Showing the default map center') | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| loadLocation() | ||
|
|
||
| return () => { | ||
| cancelled = true | ||
| } | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| if ( | ||
| !isMapLibreReady || | ||
| !location || | ||
| !mapContainerRef.current || | ||
| mapRef.current | ||
| ) { | ||
| return | ||
| } | ||
|
|
||
| const maplibregl = window.maplibregl | ||
|
|
||
| if (!maplibregl) { | ||
| setStatus('Map library failed to load') | ||
| return | ||
| } | ||
|
|
||
| const center: [number, number] = [ | ||
| location.coordinates.longitude, | ||
| location.coordinates.latitude, | ||
| ] | ||
| const map = new maplibregl.Map({ | ||
| container: mapContainerRef.current, | ||
| style: MAP_STYLE, | ||
| center, | ||
| zoom: 16, | ||
| pitch: 60, | ||
| bearing: -20, | ||
| antialias: true, | ||
| attributionControl: { compact: true }, | ||
| }) | ||
|
|
||
| map.addControl( | ||
| new maplibregl.NavigationControl({ visualizePitch: true }), | ||
| 'top-right', | ||
| ) | ||
| map.addControl( | ||
| new maplibregl.GeolocateControl({ | ||
| positionOptions: browserLocationOptions, | ||
| trackUserLocation: true, | ||
| showUserHeading: true, | ||
| }), | ||
| 'top-right', | ||
| ) | ||
| map.addControl(new maplibregl.FullscreenControl(), 'top-right') | ||
|
|
||
| markerRef.current = new maplibregl.Marker({ color: '#38bdf8' }) | ||
| .setLngLat(center) | ||
| .addTo(map) | ||
| mapRef.current = map | ||
|
|
||
| return () => { | ||
| markerRef.current?.remove() | ||
| map.remove() | ||
| markerRef.current = null | ||
| mapRef.current = null | ||
| } | ||
| }, [isMapLibreReady, location]) | ||
|
Comment on lines
+157
to
+214
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The To fix this, you should separate the map initialization from location updates. You can track whether the location has been loaded using a boolean flag (e.g., |
||
|
|
||
| useEffect(() => { | ||
| if (!location || !mapRef.current || !markerRef.current) { | ||
| return | ||
| } | ||
|
|
||
| const center: [number, number] = [ | ||
| location.coordinates.longitude, | ||
| location.coordinates.latitude, | ||
| ] | ||
|
|
||
| markerRef.current.setLngLat(center) | ||
| mapRef.current.flyTo({ | ||
| center, | ||
| zoom: 16, | ||
| pitch: 60, | ||
| bearing: -20, | ||
| duration: 1200, | ||
| }) | ||
| }, [location]) | ||
|
|
||
| return ( | ||
| <> | ||
| <Head> | ||
| <title>3D Map Wake Lock</title> | ||
| <link | ||
| rel='stylesheet' | ||
| href='https://unpkg.com/maplibre-gl@5.6.2/dist/maplibre-gl.css' | ||
| /> | ||
| </Head> | ||
| <Script | ||
| src='https://unpkg.com/maplibre-gl@5.6.2/dist/maplibre-gl.js' | ||
| strategy='afterInteractive' | ||
| onLoad={() => setIsMapLibreReady(true)} | ||
| onError={() => setStatus('Map library failed to load')} | ||
| /> | ||
|
|
||
| <div className='fixed inset-0 cursor-auto bg-slate-950 text-white'> | ||
| <div ref={mapContainerRef} className='h-screen w-screen' /> | ||
|
|
||
| <div className='pointer-events-none fixed left-4 top-4 z-10 max-w-sm rounded-2xl border border-white/15 bg-slate-950/75 p-4 shadow-2xl backdrop-blur'> | ||
| <div className='flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.25em] text-sky-300'> | ||
| <MapPin size={16} /> | ||
| 3D Map | ||
| </div> | ||
| <p className='mt-2 text-sm text-slate-100'>{status}</p> | ||
| {location && ( | ||
| <p className='mt-2 font-mono text-xs text-slate-300'> | ||
| {location.coordinates.latitude.toFixed(5)},{' '} | ||
| {location.coordinates.longitude.toFixed(5)} · {location.label} | ||
| </p> | ||
| )} | ||
| </div> | ||
| </div> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| const MapPage = () => { | ||
| return ( | ||
| <Page> | ||
| <FullScreen3DMap /> | ||
| </Page> | ||
| ) | ||
| } | ||
|
|
||
| export default MapPage | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛑 Security Vulnerability: Hardcoded API token exposes credentials in source code. Move the ipinfo.io API token to an environment variable to prevent unauthorized access and token theft.1
Footnotes
CWE-798: Use of Hard-coded Credentials - https://cwe.mitre.org/data/definitions/798.html ↩