import { Hono } from "hono"; import { getClientIp, isLocalhost } from "../lib/ip"; import { prisma } from "../lib/prisma"; import { redis } from "../lib/redis"; import { spend } from "../lib/wallet"; import { broadcast } from "../realtime"; const alert = new Hono(); const COOLDOWN_MS = 60_000; // server-enforced global cooldown const MAX_DURATION_MS = 5_000; // server clamps how long the sound may play const ALERT_PRICE = 999; // centi-credits per fire (consumable) const COOLDOWN_KEY = "xip:alert:cooldown"; // POST /api/alert { soundUrl? } alert.post("/", async (c) => { const ip = getClientIp(c); let body: { soundUrl?: string } = {}; try { body = await c.req.json(); } catch { /* no body is fine */ } // Must own the audio-alert entitlement (localhost bypasses). if (!isLocalhost(ip)) { const owned = await prisma.entitlement.findFirst({ where: { ip, kind: "audio-alert", active: true }, }); if (!owned) { return c.json({ error: "Débloque l'alerte audio dans le Shop" }, 402); } } // Global cooldown via Redis NX+PX. const ok = await redis .set(COOLDOWN_KEY, ip, "PX", COOLDOWN_MS, "NX") .catch(() => null); if (ok !== "OK") { const ttl = await redis.pttl(COOLDOWN_KEY).catch(() => 0); return c.json({ error: "Cooldown actif", retryInMs: Math.max(0, ttl) }, 429); } // Charge the consumable (skipped for localhost free mode). try { await spend(ip, ALERT_PRICE, "audio-alert"); } catch { await redis.del(COOLDOWN_KEY).catch(() => {}); return c.json({ error: "Crédits insuffisants" }, 402); } // Validate a supplied mp3 URL (must be one of our own /api/uploads/ paths). let soundUrl: string | undefined; if (typeof body.soundUrl === "string" && body.soundUrl.includes("/api/uploads/")) { soundUrl = body.soundUrl; } broadcast({ type: "alert", data: { ip, soundUrl, maxDurationMs: MAX_DURATION_MS, volume: 1 }, }); return c.json({ ok: true }); }); export default alert;