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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user