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 { recordMessage } from "../lib/stats";
|
||||||
import { broadcastNewMessage } from "../realtime";
|
import { broadcastNewMessage } from "../realtime";
|
||||||
import { getPerksForIp, getPerksForIps } from "../lib/perks";
|
import { getPerksForIp, getPerksForIps } from "../lib/perks";
|
||||||
|
import { getGeoForIp, getGeoForIps } from "../lib/geo";
|
||||||
|
|
||||||
const messages = new Hono();
|
const messages = new Hono();
|
||||||
|
|
||||||
@@ -41,12 +42,20 @@ messages.get("/", async (c) => {
|
|||||||
ips.add(m.authorIp);
|
ips.add(m.authorIp);
|
||||||
for (const r of m.replies) ips.add(r.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) => ({
|
const annotated = data.map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
authorPerks: perks[m.authorIp] ?? {},
|
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);
|
return c.json(annotated);
|
||||||
@@ -111,8 +120,11 @@ messages.post("/", async (c) => {
|
|||||||
// Update persistent stats and push the message to every connected tab,
|
// Update persistent stats and push the message to every connected tab,
|
||||||
// annotated with the author's perks so it renders correctly everywhere.
|
// annotated with the author's perks so it renders correctly everywhere.
|
||||||
void recordMessage(content.length, parentId !== null);
|
void recordMessage(content.length, parentId !== null);
|
||||||
const authorPerks = await getPerksForIp(ip);
|
const [authorPerks, authorGeo] = await Promise.all([
|
||||||
const enriched = { ...message, attachments, authorPerks };
|
getPerksForIp(ip),
|
||||||
|
getGeoForIp(ip),
|
||||||
|
]);
|
||||||
|
const enriched = { ...message, attachments, authorPerks, authorGeo };
|
||||||
const payload = parentId === null ? { ...enriched, replies: [] } : enriched;
|
const payload = parentId === null ? { ...enriched, replies: [] } : enriched;
|
||||||
broadcastNewMessage(payload);
|
broadcastNewMessage(payload);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
|
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
|
||||||
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
|
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
|
||||||
</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>
|
<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>
|
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,6 +42,13 @@
|
|||||||
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
|
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
|
||||||
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
|
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
|
||||||
</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>
|
<span class="ts">{{ fmt(reply.createdAt) }}</span>
|
||||||
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
|
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
|
||||||
<RichContent
|
<RichContent
|
||||||
@@ -51,7 +65,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { getIpColorWithPerks, getIpGlowWithPerks, getIpColor, getIpGlow } from '@/composables/ipColor';
|
||||||
import { usePerks } from '@/composables/usePerks';
|
import { usePerks } from '@/composables/usePerks';
|
||||||
import { openContextMenu } from '@/composables/useContextMenu';
|
import { openContextMenu } from '@/composables/useContextMenu';
|
||||||
@@ -160,6 +174,23 @@ function openIpMenu(e: MouseEvent, ip: string): void {
|
|||||||
function fmt(date: string): string {
|
function fmt(date: string): string {
|
||||||
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -234,4 +265,34 @@ function fmt(date: string): string {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding-left: 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ export function useMyPerks() {
|
|||||||
return { myPerks };
|
return { myPerks };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeoInfo {
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
city: string;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Reply {
|
export interface Reply {
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -20,6 +28,7 @@ export interface Reply {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
authorPerks?: Perks;
|
authorPerks?: Perks;
|
||||||
|
authorGeo?: GeoInfo | null;
|
||||||
richMode?: 'none' | 'htmlcss' | 'js';
|
richMode?: 'none' | 'htmlcss' | 'js';
|
||||||
richContent?: string | null;
|
richContent?: string | null;
|
||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
@@ -126,6 +135,7 @@ export function useMessages() {
|
|||||||
createdAt: raw.createdAt,
|
createdAt: raw.createdAt,
|
||||||
parentId: raw.parentId,
|
parentId: raw.parentId,
|
||||||
authorPerks: raw.authorPerks,
|
authorPerks: raw.authorPerks,
|
||||||
|
authorGeo: raw.authorGeo,
|
||||||
richMode: raw.richMode,
|
richMode: raw.richMode,
|
||||||
richContent: raw.richContent,
|
richContent: raw.richContent,
|
||||||
attachments: raw.attachments,
|
attachments: raw.attachments,
|
||||||
|
|||||||
Reference in New Issue
Block a user