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);

View File

@@ -9,6 +9,13 @@
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span>
<span v-if="message.authorGeo && geoLabel(message.authorGeo)" class="geo-tag">
<a :href="geoLink(message.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="message.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`" :alt="message.authorGeo.countryCode" class="geo-flag" />
<span v-else>🏠</span>
{{ geoLabel(message.authorGeo) }}
</a>
</span>
<span class="ts">{{ fmt(message.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
</div>
@@ -35,6 +42,13 @@
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
</span>
<span v-if="reply.authorGeo && geoLabel(reply.authorGeo)" class="geo-tag geo-tag--sm">
<a :href="geoLink(reply.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="reply.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${reply.authorGeo.countryCode.toLowerCase()}.png`" :alt="reply.authorGeo.countryCode" class="geo-flag" />
<span v-else>🏠</span>
{{ geoLabel(reply.authorGeo) }}
</a>
</span>
<span class="ts">{{ fmt(reply.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
<RichContent
@@ -51,7 +65,7 @@
</template>
<script setup lang="ts">
import type { Message, Reply } from '@/composables/useMessages';
import type { Message, Reply, GeoInfo } from '@/composables/useMessages';
import { getIpColorWithPerks, getIpGlowWithPerks, getIpColor, getIpGlow } from '@/composables/ipColor';
import { usePerks } from '@/composables/usePerks';
import { openContextMenu } from '@/composables/useContextMenu';
@@ -160,6 +174,23 @@ function openIpMenu(e: MouseEvent, ip: string): void {
function fmt(date: string): string {
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
function geoLabel(geo?: GeoInfo | null): string {
if (!geo) return '';
if (!geo.countryCode) return 'Local';
const place = geo.city || geo.country;
if (geo.lat != null && geo.lon != null) {
const lat = geo.lat.toFixed(4);
const lon = geo.lon.toFixed(4);
return `${place} · ${lat}, ${lon}`;
}
return place;
}
function geoLink(geo?: GeoInfo | null): string {
if (!geo || geo.lat == null || geo.lon == null) return 'https://maps.google.com';
return `https://www.google.com/maps/search/?api=1&query=${geo.lat},${geo.lon}`;
}
</script>
<style scoped>
@@ -234,4 +265,34 @@ function fmt(date: string): string {
font-size: 12px;
padding-left: 0;
}
.geo-tag {
font-family: Arial, sans-serif;
font-size: 10px;
color: #44445a;
white-space: nowrap;
}
.geo-tag--sm { font-size: 9px; }
.geo-link {
color: inherit;
text-decoration: none;
opacity: 0.7;
display: inline-flex;
align-items: center;
gap: 4px;
transition: opacity 0.12s, color 0.12s;
}
.geo-link:hover {
color: #5588cc;
opacity: 1;
text-decoration: underline;
}
.geo-flag {
width: 16px;
height: 12px;
object-fit: cover;
border-radius: 2px;
vertical-align: middle;
flex-shrink: 0;
}
</style>

View File

@@ -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,