feat: marketplace, économie à crédits, perks temps réel & pubs réelles

Transforme XIP en réseau social satirique complet : monnaie fictive,
marketplace, cosmétiques visibles de tous, messages riches sandboxés,
pubs pilotées par les données, et tous les compteurs mock rendus réels.

Backend (Bun + Hono + Prisma + Redis)
- Économie par IP : modèles Wallet/Purchase/Entitlement, lib/wallet.ts
  avec spend() atomique (point unique du paywall) + recharge gratuite.
- isLocalhost() → mode gratuit (README « si localhost: pas de paywall »).
- Marketplace : lib/catalog.ts (achat transactionnel, stock limité,
  limites par IP) + routes/shop.ts ; 10 produits seedés (idempotent).
- Perks : lib/perks.ts (cache Redis busté à l'achat) ; authorPerks
  injecté dans les payloads messages + endpoint batch /api/perks ;
  frame WS « perks » global pour MAJ live des messages déjà affichés.
- Messages riches : Message.richMode/richContent, gating par entitlement.
- Pubs réelles : modèle Ad seedé avec les 4 pubs (ex-hardcodées),
  rotation par API, comptage d'impressions réel + réconciliation.
- WebSocket : IP capturée par connexion → broadcastToIp / broadcast ;
  frames wallet/perks/ads/alert.
- Pièces jointes : lib/storage.ts (UUID, jamais exécuté) + routes/uploads.ts
  (limite 1 Mo sauf déblocage/localhost, Content-Disposition: attachment).
- Alerte audio : routes/alert.ts (cooldown serveur Redis NX, clamp durée).
- Compteur « argent extorqué » réel : impressions×CPM + crédits dépensés.

Frontend (Vue 3 + Vite)
- /shop : ShopPage + ProductCard fidèles aux maquettes ; composables
  useWallet/useShop/usePerks/useAds/useAttachments/useAlert.
- UI de réponse (bannière + sous-threads), solde + lien Shop dans le header.
- Perks rendus : Style Doré (or), Pets autour de l'IP, NoAds masque les pubs.
- RichContent.vue : iframe sandbox verrouillée (htmlcss sans script ;
  js allow-scripts seul, jamais allow-same-origin) + CSP.
- AdBand/InlineCasinoAd pilotés par l'API ; barre de saisie avec 📎,
  compteur de caractères, composer riche et bouton alerte.

Infra
- Migration economy_ads_attachments_rich ; seed idempotent (produits+pubs).
- vite.config : usePolling (HMR fiable sur /mnt/c via WSL).
- backend/.gitignore : uploads/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 22:47:23 +02:00
parent 97f6fdaeae
commit cf239ab95f
46 changed files with 4080 additions and 198 deletions

40
backend/src/routes/ads.ts Normal file
View File

@@ -0,0 +1,40 @@
import { Hono } from "hono";
import { listActiveAds, recordImpressions } from "../lib/ads";
const ads = new Hono();
// GET /api/ads?kind=band → active ad set for that slot (client rotates).
ads.get("/", async (c) => {
const kind = c.req.query("kind") === "casino" ? "casino" : "band";
const list = await listActiveAds(kind);
// Expose only what the UI needs.
return c.json(
list.map((a) => ({
id: a.id,
brand: a.brand,
subtitle: a.subtitle,
url: a.url,
cta: a.cta,
icon: a.icon,
tone: a.tone,
kind: a.kind,
ownerIp: a.ownerIp,
imageUrl: a.imageUrl,
}))
);
});
// POST /api/ads/impressions { ids: [...] }
ads.post("/impressions", async (c) => {
let body: { ids?: string[] } = {};
try {
body = await c.req.json();
} catch {
return c.json({ error: "JSON invalide" }, 400);
}
const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
await recordImpressions(ids);
return c.json({ ok: true, counted: ids.length });
});
export default ads;

View File

@@ -0,0 +1,67 @@
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;

View File

@@ -1,29 +1,68 @@
import { Hono } from "hono";
import { prisma } from "../lib/prisma";
import { getClientIp, isLocalhost } from "../lib/ip";
import { recordMessage } from "../lib/stats";
import { broadcastNewMessage } from "../realtime";
import { getPerksForIp, getPerksForIps } from "../lib/perks";
const messages = new Hono();
// GET /api/messages — top-level threads with replies
const RICH_MAX = 64 * 1024; // 64 KB cap on rich markup
/** Does this IP own the entitlement needed for a rich tier? */
async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> {
if (isLocalhost(ip)) return true;
const kind = mode === "js" ? "rich-js" : "rich-htmlcss";
const now = new Date();
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });
return rows.some((e) => !e.expiresAt || e.expiresAt >= now);
}
// GET /api/messages — top-level threads with replies, annotated with author perks.
messages.get("/", async (c) => {
const data = await prisma.message.findMany({
where: { parentId: null },
orderBy: { createdAt: "desc" },
take: 50,
include: {
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
replies: {
orderBy: { createdAt: "asc" },
include: {
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
},
},
},
});
return c.json(data);
// Collect every distinct author IP (threads + replies) and resolve perks once.
const ips = new Set<string>();
for (const m of data) {
ips.add(m.authorIp);
for (const r of m.replies) ips.add(r.authorIp);
}
const perks = await getPerksForIps([...ips]);
const annotated = data.map((m) => ({
...m,
authorPerks: perks[m.authorIp] ?? {},
replies: m.replies.map((r) => ({ ...r, authorPerks: perks[r.authorIp] ?? {} })),
}));
return c.json(annotated);
});
// POST /api/messages — create a message or reply
// POST /api/messages — create a message or reply (optionally rich + attachments)
messages.post("/", async (c) => {
const ip =
c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "127.0.0.1";
const ip = getClientIp(c);
const body = await c.req.json<{ content: string; parentId?: string }>();
const body = await c.req.json<{
content: string;
parentId?: string;
richMode?: "htmlcss" | "js";
richContent?: string;
attachmentIds?: string[];
}>();
if (!body.content || body.content.trim().length === 0) {
return c.json({ error: "Content is required" }, 400);
@@ -32,15 +71,52 @@ messages.post("/", async (c) => {
return c.json({ error: "Content exceeds 267 characters" }, 400);
}
// Rich content: validate tier ownership + size.
let richMode: "none" | "htmlcss" | "js" = "none";
let richContent: string | null = null;
if (body.richMode && body.richContent && body.richContent.trim().length > 0) {
if (body.richMode !== "htmlcss" && body.richMode !== "js") {
return c.json({ error: "richMode invalide" }, 400);
}
if (!(await ownsRich(ip, body.richMode))) {
return c.json({ error: "Fonctionnalité non débloquée" }, 402);
}
if (body.richContent.length > RICH_MAX) {
return c.json({ error: "Contenu riche trop volumineux" }, 413);
}
richMode = body.richMode;
richContent = body.richContent;
}
const content = body.content.trim();
const parentId = body.parentId ?? null;
const message = await prisma.message.create({
data: {
content: body.content.trim(),
authorIp: ip,
parentId: body.parentId ?? null,
},
data: { content, authorIp: ip, parentId, richMode, richContent },
});
return c.json(message, 201);
// Link any pre-uploaded attachments owned by this IP to the new message.
let attachments: any[] = [];
if (Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0) {
await prisma.attachment.updateMany({
where: { id: { in: body.attachmentIds }, ip, messageId: null },
data: { messageId: message.id },
});
attachments = await prisma.attachment.findMany({
where: { messageId: message.id },
select: { id: true, filename: true, mimeType: true, size: true },
});
}
// 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 payload = parentId === null ? { ...enriched, replies: [] } : enriched;
broadcastNewMessage(payload);
return c.json(enriched, 201);
});
export default messages;

View File

@@ -0,0 +1,14 @@
import { Hono } from "hono";
import { getPerksForIps } from "../lib/perks";
const perks = new Hono();
// GET /api/perks?ips=a,b,c — batch perk lookup for authors already on screen.
perks.get("/", async (c) => {
const raw = c.req.query("ips") || "";
const ips = raw.split(",").map((s) => s.trim()).filter(Boolean);
if (ips.length === 0) return c.json({});
return c.json(await getPerksForIps(ips));
});
export default perks;

View File

@@ -0,0 +1,84 @@
import { Hono } from "hono";
import { getClientIp } from "../lib/ip";
import { getWallet } from "../lib/wallet";
import {
listProducts,
getProduct,
getEntitlements,
purchase,
refreshPerks,
PurchaseError,
type PurchaseOptions,
} from "../lib/catalog";
import { broadcast, broadcastToIp } from "../realtime";
const shop = new Hono();
// GET /api/shop/products?category=cosmetiques
shop.get("/products", async (c) => {
const category = c.req.query("category") || undefined;
return c.json(await listProducts(category));
});
// GET /api/shop/products/:id
shop.get("/products/:id", async (c) => {
const p = await getProduct(c.req.param("id"));
if (!p) return c.json({ error: "Produit introuvable" }, 404);
return c.json(p);
});
// GET /api/shop/me — my balance + owned entitlements
shop.get("/me", async (c) => {
const ip = getClientIp(c);
const [wallet, entitlements] = await Promise.all([
getWallet(ip),
getEntitlements(ip),
]);
return c.json({ wallet, entitlements });
});
// POST /api/shop/purchase { productId, options }
shop.post("/purchase", async (c) => {
const ip = getClientIp(c);
let body: { productId?: string; options?: PurchaseOptions } = {};
try {
body = await c.req.json();
} catch {
return c.json({ error: "Corps JSON invalide" }, 400);
}
if (!body.productId) return c.json({ error: "productId requis" }, 400);
try {
const { result, visiblePerkChanged, adCreated } = await purchase(
ip,
body.productId,
body.options ?? {}
);
// Wallet update → only this IP's tabs.
const wallet = await getWallet(ip);
broadcastToIp(ip, { type: "wallet", data: wallet });
// Perks: always tell the buyer; if a *visible* perk changed, tell everyone
// so existing messages by this IP re-render with the skin/pet.
const perks = await refreshPerks(ip);
if (visiblePerkChanged) {
broadcast({ type: "perks", data: { ip, perks } });
} else {
broadcastToIp(ip, { type: "perks", data: { ip, perks } });
}
// New user ad entered rotation → nudge everyone to refetch ads.
if (adCreated) broadcast({ type: "ads", data: { reason: "new-user-ad" } });
return c.json(result, 201);
} catch (e) {
if (e instanceof PurchaseError) {
return c.json({ error: e.message }, e.status as 400);
}
console.error("purchase error:", (e as Error).message);
return c.json({ error: "Achat impossible" }, 500);
}
});
export default shop;

View File

@@ -0,0 +1,93 @@
import { Hono } from "hono";
import { randomUUID } from "node:crypto";
import { prisma } from "../lib/prisma";
import { getClientIp, isLocalhost } from "../lib/ip";
import { storeFile, absolutePathFor } from "../lib/storage";
const uploads = new Hono();
const FREE_LIMIT = 1_000_000; // 1 Mo for the free tier (README)
const ABSOLUTE_MAX = 50_000_000; // hard cap even for paid, to protect the dev box
async function ownsNoFileLimit(ip: string): Promise<boolean> {
if (isLocalhost(ip)) return true;
const rows = await prisma.entitlement.findMany({
where: { ip, kind: "no-file-limit", active: true },
});
return rows.length > 0;
}
// POST /api/uploads (multipart) — store a file, return its metadata.
uploads.post("/", async (c) => {
const ip = getClientIp(c);
let body: Record<string, unknown>;
try {
body = await c.req.parseBody();
} catch {
return c.json({ error: "Upload invalide" }, 400);
}
const file = body["file"];
if (!(file instanceof File)) {
return c.json({ error: "Aucun fichier" }, 400);
}
if (file.size > ABSOLUTE_MAX) {
return c.json({ error: "Fichier trop volumineux (50 Mo max absolu)" }, 413);
}
if (file.size > FREE_LIMIT && !(await ownsNoFileLimit(ip))) {
return c.json(
{ error: "Fichier > 1 Mo : débloque « Fichiers illimités » dans le Shop 💸" },
413
);
}
const id = randomUUID();
let stored;
try {
stored = await storeFile(id, file);
} catch {
return c.json({ error: "Échec d'écriture" }, 500);
}
const attachment = await prisma.attachment.create({
data: {
id,
ip,
filename: file.name || "fichier",
mimeType: file.type || "application/octet-stream",
size: file.size,
storagePath: stored.storagePath,
},
select: { id: true, filename: true, mimeType: true, size: true },
});
return c.json(attachment, 201);
});
// GET /uploads/:id — serve the stored bytes. Images inline; everything else is
// forced to download (never rendered same-origin, never executed).
uploads.get("/:id", async (c) => {
const id = c.req.param("id");
const att = await prisma.attachment.findUnique({ where: { id } });
if (!att) return c.json({ error: "Introuvable" }, 404);
let file;
try {
file = Bun.file(absolutePathFor(att.storagePath));
} catch {
return c.json({ error: "Introuvable" }, 404);
}
if (!(await file.exists())) return c.json({ error: "Introuvable" }, 404);
const isImage = att.mimeType.startsWith("image/");
const headers: Record<string, string> = {
// Images may render inline; anything else downloads. Never serve as HTML.
"Content-Type": isImage ? att.mimeType : "application/octet-stream",
"Content-Disposition": `${isImage ? "inline" : "attachment"}; filename="${att.filename.replace(/"/g, "")}"`,
"X-Content-Type-Options": "nosniff",
};
return new Response(file, { headers });
});
export default uploads;

View File

@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { getClientIp } from "../lib/ip";
import { getWallet, topUp } from "../lib/wallet";
import { broadcastToIp } from "../realtime";
const wallet = new Hono();
// GET /api/wallet — current balance + freeMode for the calling IP.
wallet.get("/", async (c) => {
return c.json(await getWallet(getClientIp(c)));
});
// POST /api/wallet/topup — free, instant, satirical recharge.
wallet.post("/topup", async (c) => {
const ip = getClientIp(c);
const view = await topUp(ip);
// Push the new balance to every tab of this IP.
broadcastToIp(ip, { type: "wallet", data: view });
return c.json(view);
});
export default wallet;