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:
40
backend/src/routes/ads.ts
Normal file
40
backend/src/routes/ads.ts
Normal 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;
|
||||
67
backend/src/routes/alert.ts
Normal file
67
backend/src/routes/alert.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
14
backend/src/routes/perks.ts
Normal file
14
backend/src/routes/perks.ts
Normal 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;
|
||||
84
backend/src/routes/shop.ts
Normal file
84
backend/src/routes/shop.ts
Normal 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;
|
||||
93
backend/src/routes/uploads.ts
Normal file
93
backend/src/routes/uploads.ts
Normal 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;
|
||||
22
backend/src/routes/wallet.ts
Normal file
22
backend/src/routes/wallet.ts
Normal 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;
|
||||
Reference in New Issue
Block a user