feat: add geolocation support for messages and replies
All checks were successful
Deploy XIP / deploy (push) Successful in 53s

This commit is contained in:
arussac
2026-05-31 16:42:32 +02:00
parent 9354e2022a
commit 366d4e8f8b
4 changed files with 183 additions and 5 deletions

95
backend/src/lib/geo.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* Best-effort IP geolocation using ip-api.com (free, no key required).
* Results are cached in Redis for 24 h so repeated lookups don't burn the
* rate-limit (45 req/min on the free tier).
*
* Private / loopback addresses always resolve to "Local" without a network call.
*/
import { redis } from "./redis";
import { isLocalhost } from "./ip";
export interface GeoInfo {
country: string;
countryCode: string; // ISO 3166-1 alpha-2, or "" for local
city: string;
lat?: number;
lon?: number;
}
const GEO_TTL = 60 * 60 * 24; // 24 h
const geoKey = (ip: string) => `xip:geo:v2:${ip}`;
function isPrivate(ip: string): boolean {
if (isLocalhost(ip)) return true;
// RFC-1918 private ranges and link-local
return (
ip.startsWith("10.") ||
ip.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
ip.startsWith("169.254.") ||
ip.startsWith("fc") ||
ip.startsWith("fd")
);
}
/** Resolve geo for a batch of IPs. Uses the ip-api.com /batch endpoint.
* Private IPs are resolved locally; real IPs are fetched and cached. */
export async function getGeoForIps(ips: string[]): Promise<Record<string, GeoInfo | null>> {
const result: Record<string, GeoInfo | null> = {};
const toFetch: string[] = [];
for (const ip of ips) {
if (isPrivate(ip)) {
result[ip] = { country: "Local", countryCode: "", city: "" };
continue;
}
const cached = await redis.get(geoKey(ip)).catch(() => null);
if (cached) {
try {
result[ip] = JSON.parse(cached) as GeoInfo;
continue;
} catch { /* fall through */ }
}
toFetch.push(ip);
}
if (toFetch.length === 0) return result;
// Batch lookup via ip-api.com
try {
const res = await fetch("http://ip-api.com/batch?fields=status,query,country,countryCode,city,lat,lon", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toFetch.map((ip) => ({ query: ip }))),
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
const list = (await res.json()) as any[];
for (const item of list) {
if (item.status === "success") {
const info: GeoInfo = {
country: item.country ?? "",
countryCode: item.countryCode ?? "",
city: item.city ?? "",
lat: item.lat,
lon: item.lon,
};
result[item.query] = info;
await redis.set(geoKey(item.query), JSON.stringify(info), "EX", GEO_TTL).catch(() => {});
} else {
result[item.query] = null;
}
}
}
} catch {
for (const ip of toFetch) if (!(ip in result)) result[ip] = null;
}
return result;
}
/** Single-IP variant used by the POST /messages broadcast path. */
export async function getGeoForIp(ip: string): Promise<GeoInfo | null> {
const batch = await getGeoForIps([ip]);
return batch[ip] ?? null;
}

View File

@@ -4,6 +4,7 @@ import { getClientIp, isFree } from "../lib/ip";
import { recordMessage } from "../lib/stats";
import { broadcastNewMessage } from "../realtime";
import { getPerksForIp, getPerksForIps } from "../lib/perks";
import { getGeoForIp, getGeoForIps } from "../lib/geo";
const messages = new Hono();
@@ -41,12 +42,20 @@ messages.get("/", async (c) => {
ips.add(m.authorIp);
for (const r of m.replies) ips.add(r.authorIp);
}
const perks = await getPerksForIps([...ips]);
const [perks, geo] = await Promise.all([
getPerksForIps([...ips]),
getGeoForIps([...ips]),
]);
const annotated = data.map((m) => ({
...m,
authorPerks: perks[m.authorIp] ?? {},
replies: m.replies.map((r) => ({ ...r, authorPerks: perks[r.authorIp] ?? {} })),
authorGeo: geo[m.authorIp] ?? null,
replies: m.replies.map((r) => ({
...r,
authorPerks: perks[r.authorIp] ?? {},
authorGeo: geo[r.authorIp] ?? null,
})),
}));
return c.json(annotated);
@@ -111,8 +120,11 @@ messages.post("/", async (c) => {
// Update persistent stats and push the message to every connected tab,
// annotated with the author's perks so it renders correctly everywhere.
void recordMessage(content.length, parentId !== null);
const authorPerks = await getPerksForIp(ip);
const enriched = { ...message, attachments, authorPerks };
const [authorPerks, authorGeo] = await Promise.all([
getPerksForIp(ip),
getGeoForIp(ip),
]);
const enriched = { ...message, attachments, authorPerks, authorGeo };
const payload = parentId === null ? { ...enriched, replies: [] } : enriched;
broadcastNewMessage(payload);