Files
MyIP/server/ip-timezone.js
T
jason5ng32andClaude Opus 5.5 c29e48687b Refactor(layout): split backend-only code out of common/, group frontend utils
common/ had become the backend's library: 30 of its 41 modules were
imported by nothing in the frontend, so the name no longer said what
belonged there. It now holds only what the frontend or the build imports
too; backend-only code moved to server/, flat except for datasets/ (the
dataset engine and its readers). The *-db/ dataset directories stay in
common/: self-hosting docs and Docker volumes mount those paths.

frontend/utils/ keeps its app-wide modules at the root and gains ip/
(IP / ASN shaping and validation), report/ (the report pipeline) and
features/ (the pure layer of a single feature). Paths only — no logic
changes. The Dockerfile copies server/, and the AGENTS.md files say where
a new module goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:10:02 +08:00

86 lines
3.1 KiB
JavaScript

// Derives the IANA timezone of an IP-geolocation result from the coordinates
// that same result reports, so the timezone can never contradict the city and
// country rendered beside it — a card reading "Los Angeles / Asia/Tokyo"
// because a second database disagreed about where the IP lives.
//
// This is the timezone *of a looked-up IP*. The visitor's own browser timezone
// is a separate concern living in frontend/utils/time-utils.js.
//
// Only the zone name travels to the client. The UTC offset is rendered
// frontend-side from this name: geo routes sit behind a 24h edge cache, and a
// cached offset would be an hour wrong for everyone from a DST switch until
// the entry expired.
import tzlookup from '@photostructure/tz-lookup';
// Sources spell a missing coordinate as 'N/A', null, '' or omit it. Number()
// turns null and '' into 0, which is a real point in the Gulf of Guinea, so
// coercion alone would invent a timezone — accept numbers and numeric strings
// only, and reject everything else outright.
const toCoordinate = (value) => {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
// `Etc/*` is what the lookup returns for open water. Reaching it means the
// source's coordinates are unusable, not that the IP sits in a UTC-fixed zone.
const isOpenWaterZone = (zone) => zone.startsWith('Etc/');
/**
* Resolve coordinates to an IANA zone name, or '' when they can't be trusted.
*/
export const lookupTimeZone = (latitude, longitude) => {
const lat = toCoordinate(latitude);
const lon = toCoordinate(longitude);
if (lat === null || lon === null) {
return '';
}
try {
const zone = tzlookup(lat, lon);
return isOpenWaterZone(zone) ? '' : zone;
} catch {
// Out-of-range coordinates; the source's position data is broken.
return '';
}
};
/**
* Add `timezone` to a geo payload in place, always defined so the frontend has
* one falsy check rather than a presence check. Payloads without usable
* coordinates get '' — those responses carry no city either, so the card stays
* consistent by omitting both.
*/
export const attachTimeZone = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return payload;
}
payload.timezone = lookupTimeZone(payload.latitude, payload.longitude);
return payload;
};
/**
* Route middleware that enriches a geo handler's response. Hooks res.json the
* same way cacheable() does, and on the same 2xx condition, so error bodies
* never grow the field. Handlers stay unaware of it; a new geo source inherits
* the field by adding this middleware to its route in backend-server.js.
*/
export const withTimeZone = () => (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = function (body) {
if (res.statusCode < 400) {
attachTimeZone(body);
}
return originalJson(body);
};
next();
};