feat: add geolocation support for messages and replies
All checks were successful
Deploy XIP / deploy (push) Successful in 53s
All checks were successful
Deploy XIP / deploy (push) Successful in 53s
This commit is contained in:
95
backend/src/lib/geo.ts
Normal file
95
backend/src/lib/geo.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user