diff --git a/backend/src/lib/geo.ts b/backend/src/lib/geo.ts new file mode 100644 index 0000000..a4f0f43 --- /dev/null +++ b/backend/src/lib/geo.ts @@ -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> { + const result: Record = {}; + 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 { + const batch = await getGeoForIps([ip]); + return batch[ip] ?? null; +} diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 36c08e4..e919f8a 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -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); diff --git a/frontend/src/components/MessageItem.vue b/frontend/src/components/MessageItem.vue index 5003210..e3c8412 100644 --- a/frontend/src/components/MessageItem.vue +++ b/frontend/src/components/MessageItem.vue @@ -9,6 +9,13 @@ {{ petsRight(message) }} VIP + + + + 🏠 + {{ geoLabel(message.authorGeo) }} + + {{ fmt(message.createdAt) }} @@ -35,6 +42,13 @@ {{ reply.authorIp }} {{ petsRight(reply) }} + + + + 🏠 + {{ geoLabel(reply.authorGeo) }} + + {{ fmt(reply.createdAt) }} diff --git a/frontend/src/composables/useMessages.ts b/frontend/src/composables/useMessages.ts index 198f797..5faf1f3 100644 --- a/frontend/src/composables/useMessages.ts +++ b/frontend/src/composables/useMessages.ts @@ -13,6 +13,14 @@ export function useMyPerks() { return { myPerks }; } +export interface GeoInfo { + country: string; + countryCode: string; + city: string; + lat?: number; + lon?: number; +} + export interface Reply { id: string; content: string; @@ -20,6 +28,7 @@ export interface Reply { createdAt: string; parentId?: string | null; authorPerks?: Perks; + authorGeo?: GeoInfo | null; richMode?: 'none' | 'htmlcss' | 'js'; richContent?: string | null; attachments?: Attachment[]; @@ -126,6 +135,7 @@ export function useMessages() { createdAt: raw.createdAt, parentId: raw.parentId, authorPerks: raw.authorPerks, + authorGeo: raw.authorGeo, richMode: raw.richMode, richContent: raw.richContent, attachments: raw.attachments,