Files
MyIP/api/google-map.js
jason5ng32andClaude Opus 5.5 6e40a611d3 Fix(api): require a tag-shaped language in the map proxy
Any non-empty language reached Static Maps, so every distinct value was an
uncached upstream call whose answer the edge kept for a year. A tag-shape
check still passes region-tagged UI locales (zh-TW, pt-BR) without an
allow-list, and the handler block now covers both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:35:27 +08:00

105 lines
4.8 KiB
JavaScript

import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../server/logger.js';
// Validate request legitimacy. `language` is the UI locale code, region
// subtag included (`pt-BR`, `zh-TW`); like every `?lang` it is not
// allow-listed, but it must be tag-shaped: every distinct value is an
// uncached Static Maps call whose answer is edge-cached for a year.
const LANGUAGE_TAG = /^[a-z]{2,3}(-[A-Za-z0-9]{2,4}){0,2}$/;
function isValidRequest(req) {
const isLatitudeValid = /^-?\d+(\.\d+)?$/.test(req.query.latitude);
const isLongitudeValid = /^-?\d+(\.\d+)?$/.test(req.query.longitude);
const isLanguageValid = typeof req.query.language === 'string' && LANGUAGE_TAG.test(req.query.language);
if (!isLatitudeValid || !isLongitudeValid || !isLanguageValid) {
return false;
} else {
return true;
}
}
// Define day and night mode style strings
const styles = {
Dark: [
"feature:all|element:geometry.fill|color:0x242f3e",
"feature:all|element:labels.text.stroke|color:0x242f3e",
"feature:all|element:labels.text.fill|color:0x746855",
"feature:administrative.locality|element:labels.text.fill|color:0xd59563",
"feature:poi|element:labels.text.fill|color:0xd59563",
"feature:poi.park|element:geometry|color:0x263c3f",
"feature:poi.park|element:labels.text.fill|color:0x6b9a76",
"feature:road|element:geometry|color:0x38414e",
"feature:road|element:geometry.stroke|color:0x212a37",
"feature:road|element:labels.text.fill|color:0x9ca5b3",
"feature:road.highway|element:geometry|color:0x746855",
"feature:road.highway|element:geometry.stroke|color:0x1f2835",
"feature:road.highway|element:labels.text.fill|color:0xf3d19c",
"feature:transit|element:geometry|color:0x2f3948",
"feature:transit.station|element:labels.text.fill|color:0xd59563",
"feature:water|element:geometry|color:0x17263c",
"feature:water|element:labels.text.fill|color:0x515c6d",
"feature:all|element:labels.text.stroke|color:0x17263c"
]
};
export default async (req, res) => {
// Check if request is legitimate
if (!isValidRequest(req)) {
return res.status(400).json({ error: 'Invalid request' });
}
// Use req.query to get parameters
const { latitude, longitude, language, CanvasMode } = req.query;
if (!latitude || !longitude || !language) {
return res.status(400).json({ error: 'Missing latitude, longitude, or language' });
}
// Size targets the Dialog-based map viewer: aspect-2/1 at up to ~768px wide.
// Standard Static Maps plan caps each dimension at 640, so 640x320 + scale=2
// yields a 1280x640 effective image — sharp when downscaled into the Dialog.
const mapSize = '640x320';
const fmt = 'jpg';
const scale = 2;
const zoom = 3;
const apiKeys = (process.env.GOOGLE_MAP_API_KEY || '').split(',');
const apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
let styleParam = '';
if (CanvasMode === 'Dark') {
styleParam = styles.Dark.join('&style=');
}
const url = `https://maps.googleapis.com/maps/api/staticmap?center=${latitude},${longitude}&markers=color:blue%7C${latitude},${longitude}&scale=${scale}&zoom=${zoom}&maptype=roadmap&language=${encodeURIComponent(language)}&format=${fmt}&size=${mapSize}&style=${styleParam}&key=${apiKey}`;
// Unlike the JSON handlers, the Static Maps response is a binary JPEG
// and must be passed through to the client. pipeline() propagates errors
// both ways: an upstream reset destroys the response, a client
// disconnect cancels the upstream body.
try {
const apiRes = await fetchUpstream(url);
if (!apiRes.ok) {
return res.status(apiRes.status).json({ error: `Upstream map API returned ${apiRes.status}` });
}
res.setHeader('Content-Type', apiRes.headers.get('content-type') || 'image/jpeg');
// Binary streams bypass the res.json hook in the cacheable() middleware,
// so apply the cache value it stashed on res.locals here on the 2xx path.
if (res.locals.cacheControl) {
res.setHeader('Cache-Control', res.locals.cacheControl);
}
await pipeline(Readable.fromWeb(apiRes.body), res);
} catch (e) {
// Visitors abandoning a half-loaded image are routine, not a failure.
if (e?.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
logger.error({ err: e, latitude, longitude, language, CanvasMode }, 'google-map handler failed');
if (!res.headersSent && !res.destroyed) {
return res.status(500).json({ error: e.message });
}
if (!res.destroyed) res.destroy();
}
};