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

1
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
uploads/

View File

@@ -0,0 +1,112 @@
-- AlterTable
ALTER TABLE "messages" ADD COLUMN "richContent" TEXT,
ADD COLUMN "richMode" TEXT NOT NULL DEFAULT 'none';
-- CreateTable
CREATE TABLE "wallets" (
"ip" TEXT NOT NULL,
"balance" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("ip")
);
-- CreateTable
CREATE TABLE "products" (
"id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"name" TEXT NOT NULL,
"subtitle" TEXT,
"kind" TEXT NOT NULL,
"basePrice" INTEGER NOT NULL,
"promoPrice" INTEGER,
"badge" TEXT,
"stockLimit" INTEGER,
"stockSold" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"metaJson" TEXT,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "purchases" (
"id" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"productId" TEXT,
"type" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"metaJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "purchases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "entitlements" (
"id" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"expiresAt" TIMESTAMP(3),
"metaJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ads" (
"id" TEXT NOT NULL,
"brand" TEXT NOT NULL,
"subtitle" TEXT,
"url" TEXT,
"cta" TEXT,
"icon" TEXT,
"tone" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"weight" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"ownerIp" TEXT,
"format" TEXT,
"imageUrl" TEXT,
"expiresAt" TIMESTAMP(3),
"impressions" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ads_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "attachments" (
"id" TEXT NOT NULL,
"messageId" TEXT,
"ip" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"storagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "purchases_ip_idx" ON "purchases"("ip");
-- CreateIndex
CREATE INDEX "entitlements_ip_idx" ON "entitlements"("ip");
-- CreateIndex
CREATE INDEX "entitlements_ip_kind_idx" ON "entitlements"("ip", "kind");
-- CreateIndex
CREATE INDEX "ads_kind_active_idx" ON "ads"("kind", "active");
-- CreateIndex
CREATE INDEX "attachments_messageId_idx" ON "attachments"("messageId");
-- AddForeignKey
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -11,14 +11,120 @@ datasource db {
}
model Message {
id String @id @default(uuid())
content String @db.VarChar(267)
authorIp String
createdAt DateTime @default(now())
parentId String?
id String @id @default(uuid())
content String @db.VarChar(267)
authorIp String
createdAt DateTime @default(now())
parentId String?
// Rich-message tiers (paid): "none" | "htmlcss" | "js". richContent holds the raw
// markup/script, rendered ONLY inside a sandboxed iframe on the client.
richMode String @default("none")
richContent String? @db.Text
parent Message? @relation("ThreadReplies", fields: [parentId], references: [id])
replies Message[] @relation("ThreadReplies")
parent Message? @relation("ThreadReplies", fields: [parentId], references: [id])
replies Message[] @relation("ThreadReplies")
attachments Attachment[]
@@map("messages")
}
// ── Economy: fictional "crédits XIP", keyed on IP (no accounts) ──────────────
model Wallet {
ip String @id
balance Int @default(0) // centi-credits (9.99 "€" => 999)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("wallets")
}
// Seeded catalogue of purchasable features (faithful to the shop mockups).
model Product {
id String @id // slug: "cadre-pub","noads","style-dore","pet","bundle-cosmetic","rich-htmlcss","rich-js","no-file-limit","audio-alert"
category String // "publicite" | "abonnements" | "cosmetiques" | "promotions" | "premium"
name String
subtitle String?
kind String // "ad-frame" | "subscription" | "ip-skin" | "pet" | "bundle" | "rich" | "unlock" | "consumable"
basePrice Int // centi-credits
promoPrice Int?
badge String?
stockLimit Int? // e.g. 50 for style-dore; null = unlimited
stockSold Int @default(0)
active Boolean @default(true)
sortOrder Int @default(0)
metaJson String? @db.Text // options config (durations, formats, pet designs, plans…)
@@map("products")
}
// Append-only ledger: every credit movement (top-up, purchase, grant).
model Purchase {
id String @id @default(uuid())
ip String
productId String?
type String // "topup" | "purchase" | "grant"
amount Int // signed centi-credits: negative = spend, positive = grant
metaJson String? @db.Text
createdAt DateTime @default(now())
@@index([ip])
@@map("purchases")
}
// What an IP owns. Drives perks (skin/pets/noads), rich unlocks, ad frames, etc.
model Entitlement {
id String @id @default(uuid())
ip String
kind String // "noads" | "style-dore" | "pet" | "rich-htmlcss" | "rich-js" | "no-file-limit" | "ad-frame" | "audio-alert" | "element-skin"
active Boolean @default(true)
expiresAt DateTime? // subscriptions / ad-frame duration; null = permanent
metaJson String? @db.Text // pet: {design,position}; ad-frame: {format,url,days}; etc.
createdAt DateTime @default(now())
@@index([ip])
@@index([ip, kind])
@@map("entitlements")
}
// ── Real ad inventory (replaces the hardcoded AdBand / InlineCasinoAd) ───────
model Ad {
id String @id @default(uuid())
brand String
subtitle String?
url String?
cta String?
icon String?
tone String // "blue" | "green" | "purple" | "casino" | "user"
kind String // "band" | "casino"
weight Int @default(1)
active Boolean @default(true)
ownerIp String? // set when bought via "Cadre de Pub"
format String? // "static" | "gif"
imageUrl String?
expiresAt DateTime?
impressions Int @default(0)
createdAt DateTime @default(now())
@@index([kind, active])
@@map("ads")
}
// ── File attachments (free <=1 Mo; paid "no-file-limit" lifts the cap) ───────
model Attachment {
id String @id @default(uuid())
messageId String?
ip String
filename String
mimeType String
size Int
storagePath String
createdAt DateTime @default(now())
message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@index([messageId])
@@map("attachments")
}

View File

@@ -2,36 +2,208 @@ import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// ── Marketplace catalogue (faithful to the shop mockups) ────────────────────
// Prices are centi-credits (mockup € → credits): 9.99 → 999.
const PRODUCTS = [
{
id: "cadre-pub",
category: "publicite",
name: "Cadre de Pub",
subtitle: "1 000 impressions garanties · 130×180 px · lien cliquable",
kind: "ad-frame",
basePrice: 1500,
promoPrice: 999,
badge: "-33% FLASH PROMO",
sortOrder: 10,
metaJson: JSON.stringify({
durations: [
{ days: 7, extra: 0 },
{ days: 14, extra: 800 },
{ days: 30, extra: 2000 },
],
formats: [
{ id: "static", label: "Image statique", extra: 0 },
{ id: "gif", label: "GIF animé", extra: 300 },
],
}),
},
{
id: "noads",
category: "abonnements",
name: "Abonnement NoAds",
subtitle: "Supprime toutes les pubs du chat",
kind: "subscription",
basePrice: 499,
badge: "POPULAIRE",
sortOrder: 20,
metaJson: JSON.stringify({
plans: [
{ id: "monthly", label: "Mensuel", price: 499 },
{ id: "annual", label: "Annuel", price: 3999 },
],
}),
},
{
id: "style-dore",
category: "cosmetiques",
name: "Style Doré",
subtitle: "Ton IP en or brillant, visible de tous",
kind: "ip-skin",
basePrice: 999,
badge: "LIMITÉ 50 ex.",
stockLimit: 50,
sortOrder: 30,
metaJson: JSON.stringify({ variant: "gold" }),
},
{
id: "pet",
category: "cosmetiques",
name: "Pet de Nom",
subtitle: "Un petit élément décoratif autour de ton IP",
kind: "pet",
basePrice: 799,
badge: "NOUVEAU",
sortOrder: 40,
metaJson: JSON.stringify({
designs: [
{ id: "coeur", char: "♥" },
{ id: "etoile", char: "★" },
{ id: "diamant", char: "♦" },
{ id: "trefle", char: "♣" },
{ id: "couronne", char: "♚" },
{ id: "crane", char: "☠" },
{ id: "eclair", char: "⚡" },
{ id: "fleur", char: "✿" },
{ id: "note", char: "♫" },
{ id: "feu", char: "🔥" },
],
positions: ["left", "right", "both"],
}),
},
{
id: "bundle-cosmetic",
category: "promotions",
name: "Pack Cosmétique",
subtitle: "Style Doré + 1 Pet au choix",
kind: "bundle",
basePrice: 1798,
promoPrice: 1499,
badge: "-3 CR",
sortOrder: 50,
metaJson: JSON.stringify({ includes: ["style-dore", "pet"] }),
},
{
// id == entitlement kind, so the "unlock" branch grants "element-skin".
id: "element-skin",
category: "cosmetiques",
name: "Skin d'éléments",
subtitle: "Relooke ta barre de saisie et ton bouton d'envoi",
kind: "unlock",
basePrice: 599,
sortOrder: 45,
metaJson: JSON.stringify({}),
},
{
id: "rich-htmlcss",
category: "premium",
name: "Messages HTML / CSS",
subtitle: "Mets en forme tes messages (sans script)",
kind: "rich",
basePrice: 2999,
sortOrder: 60,
metaJson: JSON.stringify({}),
},
{
id: "rich-js",
category: "premium",
name: "Messages JavaScript",
subtitle: "Scripts interactifs (isolés). TRÈS cher.",
kind: "rich",
basePrice: 19999,
badge: "TRÈS TRÈS CHER",
sortOrder: 70,
metaJson: JSON.stringify({}),
},
{
id: "no-file-limit",
category: "premium",
name: "Fichiers illimités",
subtitle: "Plus de limite de 1 Mo sur tes pièces jointes",
kind: "unlock",
basePrice: 1499,
sortOrder: 80,
metaJson: JSON.stringify({}),
},
{
id: "audio-alert",
category: "premium",
name: "Alerte audio générale",
subtitle: "Fais hurler un son chez tout le monde (cooldown)",
kind: "consumable",
basePrice: 999,
badge: "CONSOMMABLE",
sortOrder: 90,
metaJson: JSON.stringify({ cooldownMs: 60000, maxDurationMs: 5000 }),
},
] as const;
// ── Ad inventory (the 4 hardcoded joke ads, now real data) ──────────────────
const ADS = [
{ brand: "NOVA", subtitle: "STORE 2026", url: "https://nova-store.io", cta: "DÉCOUVRIR", icon: "🛒", tone: "blue", kind: "band", weight: 1 },
{ brand: "APEX GEAR", subtitle: "Gaming Setup", url: "https://apex-gear.com", cta: "ACHETER", icon: "🎮", tone: "green", kind: "band", weight: 1 },
{ brand: "SHIELDVPN", subtitle: "Sécurité totale", url: "https://shieldvpn.net", cta: "ESSAI GRATUIT", icon: "🔒", tone: "purple", kind: "band", weight: 1 },
{ brand: "CASINO LUCKY", subtitle: "OFFRE EXCLUSIVE · +200% · 500€ max", url: "https://casino-lucky.bet", cta: "JOUER MAINTENANT", icon: "♠", tone: "casino", kind: "casino", weight: 1 },
] as const;
async function seedProducts() {
for (const p of PRODUCTS) {
await prisma.product.upsert({
where: { id: p.id },
create: p as any,
update: p as any,
});
}
console.log(`${PRODUCTS.length} produits upsertés.`);
}
async function seedAds() {
for (const a of ADS) {
// Idempotent on brand: only seed the canonical (non-user) ads once.
const existing = await prisma.ad.findFirst({ where: { brand: a.brand, ownerIp: null } });
if (existing) {
await prisma.ad.update({ where: { id: existing.id }, data: a as any });
} else {
await prisma.ad.create({ data: a as any });
}
}
console.log(`${ADS.length} pubs upsertées.`);
}
async function seedMessages() {
const count = await prisma.message.count();
if (count > 0) {
console.log("⏭️ Database already seeded, skipping.");
console.log("⏭️ Messages déjà présents, seed messages ignoré.");
return;
}
const root1 = await prisma.message.create({
data: {
content: "Bienvenue sur XIP — le réseau social sans filtre ni compte.",
authorIp: "1.2.3.4",
},
});
await prisma.message.create({
data: {
content: "Pas de compte, ton IP c'est toi.",
authorIp: "5.6.7.8",
},
data: { content: "Pas de compte, ton IP c'est toi.", authorIp: "5.6.7.8" },
});
await prisma.message.create({
data: {
content: "Réponse au premier message !",
authorIp: "9.10.11.12",
parentId: root1.id,
},
data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id },
});
console.log("✅ 3 messages de démo créés.");
}
console.log("✅ Database seeded with 3 messages.");
async function main() {
await seedProducts();
await seedAds();
await seedMessages();
}
main()

View File

@@ -2,9 +2,25 @@ import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import messagesRoute from "./routes/messages";
import walletRoute from "./routes/wallet";
import shopRoute from "./routes/shop";
import perksRoute from "./routes/perks";
import uploadsRoute from "./routes/uploads";
import adsRoute from "./routes/ads";
import alertRoute from "./routes/alert";
import { wsHandler, websocket } from "./realtime";
import { recordIp, initStats } from "./lib/stats";
import { initImpressionTotal, reconcileImpressions } from "./lib/ads";
import { getClientIp } from "./lib/ip";
const app = new Hono();
// Backfill persistent counters from the DB on first boot (idempotent).
void initStats();
void initImpressionTotal();
// Periodically fold Redis impression counters into the DB.
setInterval(() => void reconcileImpressions(), 30_000);
app.use("*", logger());
app.use(
"*",
@@ -15,10 +31,27 @@ app.use(
})
);
// Count every IP that passes through the server (HyperLogLog, approximate).
app.use("*", async (c, next) => {
void recordIp(getClientIp(c));
await next();
});
app.get("/health", (c) => c.json({ status: "ok" }));
// Realtime stats + live message feed.
app.get("/ws", wsHandler);
app.route("/api/messages", messagesRoute);
app.route("/api/wallet", walletRoute);
app.route("/api/shop", shopRoute);
app.route("/api/perks", perksRoute);
app.route("/api/uploads", uploadsRoute);
app.route("/api/ads", adsRoute);
app.route("/api/alert", alertRoute);
export default {
port: Number(process.env.PORT) || 3000,
fetch: app.fetch,
websocket,
};

71
backend/src/lib/ads.ts Normal file
View File

@@ -0,0 +1,71 @@
import { prisma } from "./prisma";
import { redis } from "./redis";
/**
* Ad inventory access + impression counting.
*
* Active, non-expired ads are served by kind ("band" | "casino"). Impressions
* are counted cheaply in Redis (xip:ad:impressions:<id> + a global total) and
* periodically reconciled into Ad.impressions for durability.
*/
const IMP_PREFIX = "xip:ad:impressions:";
const IMP_TOTAL = "xip:money:impressions_total";
export async function listActiveAds(kind: "band" | "casino") {
const now = new Date();
const ads = await prisma.ad.findMany({
where: { kind, active: true },
orderBy: { createdAt: "asc" },
});
return ads.filter((a) => !a.expiresAt || a.expiresAt >= now);
}
/** Record N impressions for a set of ad ids (best-effort, Redis only). */
export async function recordImpressions(ids: string[]): Promise<void> {
if (!ids.length) return;
const pipe = redis.pipeline();
for (const id of ids) pipe.incr(IMP_PREFIX + id);
pipe.incrby(IMP_TOTAL, ids.length);
await pipe.exec().catch(() => {});
}
/** Total impressions across all ads (for the money counter). */
export async function getImpressionTotal(): Promise<number> {
const v = await redis.get(IMP_TOTAL).catch(() => "0");
return Number(v ?? 0);
}
/**
* Periodically fold the Redis per-ad impression counters into the DB so the
* Ad.impressions column stays roughly current (and survives a Redis flush).
*/
export async function reconcileImpressions(): Promise<void> {
try {
const ads = await prisma.ad.findMany({ select: { id: true } });
for (const { id } of ads) {
const key = IMP_PREFIX + id;
const v = await redis.get(key).catch(() => null);
const n = Number(v ?? 0);
if (n > 0) {
await prisma.ad.update({ where: { id }, data: { impressions: n } }).catch(() => {});
}
}
} catch {
/* best-effort */
}
}
/** Backfill the Redis impression total from the DB on first boot. */
export async function initImpressionTotal(): Promise<void> {
const exists = await redis.exists(IMP_TOTAL).catch(() => 0);
if (exists) return;
const agg = await prisma.ad.aggregate({ _sum: { impressions: true } }).catch(() => null);
const sum = agg?._sum.impressions ?? 0;
if (sum > 0) await redis.set(IMP_TOTAL, String(sum)).catch(() => {});
// Also seed per-ad keys so reconcile doesn't clobber DB values with 0.
const ads = await prisma.ad.findMany({ select: { id: true, impressions: true } }).catch(() => []);
for (const a of ads) {
if (a.impressions > 0) await redis.set(IMP_PREFIX + a.id, String(a.impressions)).catch(() => {});
}
}

308
backend/src/lib/catalog.ts Normal file
View File

@@ -0,0 +1,308 @@
import { prisma } from "./prisma";
import { spend, getWallet, InsufficientCreditsError } from "./wallet";
import { isLocalhost } from "./ip";
import { invalidatePerks, getPerksForIp } from "./perks";
/**
* Marketplace catalogue + purchase engine.
*
* Prices are centi-credits (mockup € → credits). The server is the ONLY
* authority on price, stock, and per-IP limits — the client never decides.
*/
export interface PurchaseOptions {
plan?: "monthly" | "annual"; // subscription
durationDays?: number; // ad-frame
format?: "static" | "gif"; // ad-frame
url?: string; // ad-frame destination
petDesign?: string; // pet slug
petChar?: string; // pet glyph
petPosition?: "left" | "right" | "both";
}
export interface PurchaseResult {
ok: true;
productId: string;
pricePaid: number;
balance: number;
entitlementKinds: string[];
}
export class PurchaseError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "PurchaseError";
this.status = status;
}
}
const DAY_MS = 24 * 60 * 60 * 1000;
/** Effective unit price for a product given options (promo + add-ons). */
function effectivePrice(product: any, options: PurchaseOptions): number {
let price = product.promoPrice ?? product.basePrice;
let meta: any = {};
try {
meta = product.metaJson ? JSON.parse(product.metaJson) : {};
} catch {
meta = {};
}
if (product.kind === "subscription") {
const plan = (meta.plans ?? []).find((p: any) => p.id === (options.plan ?? "monthly"));
if (plan) price = plan.price;
}
if (product.kind === "ad-frame") {
const dur = (meta.durations ?? []).find(
(d: any) => d.days === (options.durationDays ?? 7)
);
const fmt = (meta.formats ?? []).find(
(f: any) => f.id === (options.format ?? "static")
);
price += (dur?.extra ?? 0) + (fmt?.extra ?? 0);
}
return price;
}
export async function listProducts(category?: string) {
return prisma.product.findMany({
where: { active: true, ...(category ? { category } : {}) },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
}
export function getProduct(id: string) {
return prisma.product.findUnique({ where: { id } });
}
/** All entitlements an IP owns (active), for "Mes achats". */
export async function getEntitlements(ip: string) {
const now = new Date();
const rows = await prisma.entitlement.findMany({
where: { ip, active: true },
orderBy: { createdAt: "desc" },
});
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now);
}
async function countActiveEntitlements(ip: string, kind: string): Promise<number> {
const now = new Date();
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now).length;
}
/**
* Buy a product. Enforces per-IP limits + stock, spends credits atomically,
* grants the entitlement(s). Returns the new balance and granted kinds.
* Side-effect: caller should bust perks cache + broadcast (done in the route).
*/
export async function purchase(
ip: string,
productId: string,
options: PurchaseOptions = {}
): Promise<{ result: PurchaseResult; visiblePerkChanged: boolean; adCreated: boolean }> {
const product = await getProduct(productId);
if (!product || !product.active) throw new PurchaseError("Produit introuvable", 404);
const free = isLocalhost(ip);
const price = effectivePrice(product, options);
// Resolve which entitlement kind(s) this grants + per-IP limit checks.
const grants: { kind: string; expiresAt?: Date; meta?: any }[] = [];
let visiblePerkChanged = false;
let adCreated = false;
switch (product.kind) {
case "subscription": {
// NoAds: 1 active max.
if ((await countActiveEntitlements(ip, "noads")) >= 1)
throw new PurchaseError("Tu as déjà un abonnement NoAds actif", 409);
const plan = options.plan ?? "monthly";
const days = plan === "annual" ? 365 : 30;
grants.push({ kind: "noads", expiresAt: new Date(Date.now() + days * DAY_MS), meta: { plan } });
break;
}
case "ip-skin": {
// Style Doré: 1 active max + global stock cap.
if ((await countActiveEntitlements(ip, "style-dore")) >= 1)
throw new PurchaseError("Tu possèdes déjà le Style Doré", 409);
grants.push({ kind: "style-dore", meta: { variant: "gold" } });
visiblePerkChanged = true;
break;
}
case "pet": {
if ((await countActiveEntitlements(ip, "pet")) >= 3)
throw new PurchaseError("Maximum 3 pets actifs", 409);
const char = options.petChar ?? "♥";
grants.push({
kind: "pet",
meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" },
});
visiblePerkChanged = true;
break;
}
case "ad-frame": {
if ((await countActiveEntitlements(ip, "ad-frame")) >= 1)
throw new PurchaseError("Tu as déjà un cadre de pub actif", 409);
const days = options.durationDays ?? 7;
grants.push({
kind: "ad-frame",
expiresAt: new Date(Date.now() + days * DAY_MS),
meta: { format: options.format ?? "static", url: options.url ?? "", days },
});
break;
}
case "rich": {
const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss";
if ((await countActiveEntitlements(ip, kind)) >= 1)
throw new PurchaseError("Déjà débloqué", 409);
grants.push({ kind });
break;
}
case "unlock": {
// no-file-limit, element-skin, etc. — slug == kind.
if ((await countActiveEntitlements(ip, product.id)) >= 1)
throw new PurchaseError("Déjà débloqué", 409);
grants.push({ kind: product.id });
if (product.id === "element-skin") visiblePerkChanged = false; // viewer-scoped
break;
}
case "consumable": {
// audio-alert: grant the entitlement once; firing is a separate action.
if ((await countActiveEntitlements(ip, "audio-alert")) < 1) {
grants.push({ kind: "audio-alert" });
} else {
// Already owned — buying again is a harmless top-up; just record it.
grants.push({ kind: "audio-alert" });
}
break;
}
case "bundle": {
// Cosmetic bundle: Style Doré + 1 pet.
if ((await countActiveEntitlements(ip, "style-dore")) < 1)
grants.push({ kind: "style-dore", meta: { variant: "gold" } });
if ((await countActiveEntitlements(ip, "pet")) < 3) {
const char = options.petChar ?? "★";
grants.push({
kind: "pet",
meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" },
});
}
if (grants.length === 0)
throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409);
visiblePerkChanged = true;
break;
}
default:
throw new PurchaseError("Type de produit non géré", 400);
}
// Stock check for limited products (Style Doré). Done transactionally with the
// spend so we can never oversell the 50-unit cap under concurrency.
let balance = 0;
try {
balance = await prisma.$transaction(async (tx) => {
if (product.stockLimit != null) {
const fresh = await tx.product.findUnique({ where: { id: product.id } });
if (!fresh) throw new PurchaseError("Produit introuvable", 404);
if (fresh.stockSold >= fresh.stockLimit)
throw new PurchaseError("Stock épuisé", 409);
await tx.product.update({
where: { id: product.id },
data: { stockSold: { increment: 1 } },
});
}
// Spend (skips real deduction for localhost free mode).
if (!free && price > 0) {
const w = await tx.wallet.upsert({
where: { ip },
create: { ip, balance: 0 },
update: {},
});
if (w.balance < price) throw new InsufficientCreditsError();
const updated = await tx.wallet.update({
where: { ip },
data: { balance: { decrement: price } },
});
await tx.purchase.create({
data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) },
});
// Grant entitlements inside the tx too.
for (const g of grants) {
await tx.entitlement.create({
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
});
}
return updated.balance;
}
// Free (localhost) or zero-price: record purchase + grants, no deduction.
await tx.purchase.create({
data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) },
});
for (const g of grants) {
await tx.entitlement.create({
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
});
}
const w = await tx.wallet.findUnique({ where: { ip } });
return w?.balance ?? 0;
});
} catch (e) {
if (e instanceof InsufficientCreditsError)
throw new PurchaseError("Crédits insuffisants", 402);
throw e;
}
// Bump the global credits-spent money counter (outside the tx; best-effort).
if (!free && price > 0) {
const { redis } = await import("./redis");
void redis.incrby("xip:money:credits_spent", price).catch(() => {});
}
// Ad-frame purchase => create a real Ad row that enters rotation (Phase 7).
const adGrant = grants.find((g) => g.kind === "ad-frame");
if (adGrant) {
await prisma.ad
.create({
data: {
brand: "VOTRE PUB",
subtitle: "Espace acheté",
url: adGrant.meta?.url || null,
cta: "VOIR",
icon: "📣",
tone: "user",
kind: "band",
weight: 3,
active: true,
ownerIp: ip,
format: adGrant.meta?.format ?? "static",
expiresAt: adGrant.expiresAt ?? null,
},
})
.catch(() => {});
adCreated = true;
}
const balanceView = free ? (await getWallet(ip)).balance : balance;
return {
result: {
ok: true,
productId: product.id,
pricePaid: free ? 0 : price,
balance: balanceView,
entitlementKinds: grants.map((g) => g.kind),
},
visiblePerkChanged,
adCreated,
};
}
/** Recompute + cache perks after a purchase (caller broadcasts). */
export async function refreshPerks(ip: string) {
await invalidatePerks(ip);
return getPerksForIp(ip);
}

38
backend/src/lib/ip.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { Context } from "hono";
import { getConnInfo } from "hono/bun";
/**
* Best-effort client IP.
* Prefer x-forwarded-for (set when behind a proxy), fall back to the raw socket
* address from Bun. In local dev (frontend:5173 → backend:3000, no proxy) this
* is typically 127.0.0.1 / ::1.
*/
export function getClientIp(c: Context): string {
const fwd = c.req.header("x-forwarded-for");
if (fwd) {
const first = fwd.split(",")[0]?.trim();
if (first) return first;
}
try {
const addr = getConnInfo(c).remote.address;
if (addr) return addr;
} catch {
/* getConnInfo only works under the Bun adapter */
}
return "127.0.0.1";
}
/**
* Is this IP the local machine? Drives the README rule "si localhost: pas de
* paywall (tout gratuit)". Covers IPv4 loopback, IPv6 loopback, and the
* IPv4-mapped-IPv6 form Bun sometimes reports.
*/
export function isLocalhost(ip: string): boolean {
return (
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "::ffff:127.0.0.1" ||
ip === "localhost" ||
ip.startsWith("127.")
);
}

111
backend/src/lib/perks.ts Normal file
View File

@@ -0,0 +1,111 @@
import { prisma } from "./prisma";
import { redis } from "./redis";
/**
* Perks = the visible/functional consequences of an IP's active entitlements.
*
* - skin: 'gold' (Style Doré — everyone sees it)
* - pets: [{char, position}] (Pets de Nom — everyone sees them)
* - noads: true (NoAds subscription — viewer-scoped)
* - badge: true (annual NoAds — exclusive badge)
* - elementSkin: true (one cosmetic element variant — viewer-scoped)
* - richHtmlcss / richJs / noFileLimit (unlocks the composer / upload gate)
*
* Cached in Redis (short TTL) and busted on purchase so the feed updates live.
*/
export type PetPosition = "left" | "right" | "both";
export interface Perks {
skin?: "gold";
pets?: { char: string; position: PetPosition }[];
noads?: boolean;
badge?: boolean;
elementSkin?: boolean;
richHtmlcss?: boolean;
richJs?: boolean;
noFileLimit?: boolean;
}
const perksKey = (ip: string) => `xip:perks:${ip}`;
const TTL_SEC = 60;
/** Drop the cached perks for an IP. Call BEFORE broadcasting a perks change. */
export async function invalidatePerks(ip: string): Promise<void> {
await redis.del(perksKey(ip)).catch(() => {});
}
/** Compute perks for one IP from its active, non-expired entitlements. */
export async function getPerksForIp(ip: string): Promise<Perks> {
// Fast path: cache.
const cached = await redis.get(perksKey(ip)).catch(() => null);
if (cached) {
try {
return JSON.parse(cached) as Perks;
} catch {
/* fall through to recompute */
}
}
const now = new Date();
const rows = await prisma.entitlement
.findMany({ where: { ip, active: true } })
.catch(() => []);
const perks: Perks = {};
const pets: { char: string; position: PetPosition }[] = [];
for (const e of rows) {
// Skip expired (subscriptions / ad-frames).
if (e.expiresAt && e.expiresAt < now) continue;
let meta: any = {};
try {
meta = e.metaJson ? JSON.parse(e.metaJson) : {};
} catch {
meta = {};
}
switch (e.kind) {
case "style-dore":
perks.skin = "gold";
break;
case "pet":
if (meta.char) pets.push({ char: meta.char, position: meta.position ?? "left" });
break;
case "noads":
perks.noads = true;
if (meta.plan === "annual") perks.badge = true;
break;
case "element-skin":
perks.elementSkin = true;
break;
case "rich-htmlcss":
perks.richHtmlcss = true;
break;
case "rich-js":
perks.richJs = true;
break;
case "no-file-limit":
perks.noFileLimit = true;
break;
}
}
if (pets.length) perks.pets = pets.slice(0, 3);
await redis.set(perksKey(ip), JSON.stringify(perks), "EX", TTL_SEC).catch(() => {});
return perks;
}
/** Batch perks for several IPs (used to annotate message lists). */
export async function getPerksForIps(
ips: string[]
): Promise<Record<string, Perks>> {
const uniq = [...new Set(ips.filter(Boolean))];
const out: Record<string, Perks> = {};
await Promise.all(
uniq.map(async (ip) => {
out[ip] = await getPerksForIp(ip);
})
);
return out;
}

23
backend/src/lib/redis.ts Normal file
View File

@@ -0,0 +1,23 @@
import Redis from "ioredis";
const globalForRedis = globalThis as unknown as { redis?: Redis };
const REDIS_URL = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
export const redis =
globalForRedis.redis ??
new Redis(REDIS_URL, {
lazyConnect: false,
maxRetriesPerRequest: 3,
// Keep the dev server alive even if Redis hiccups; stats are best-effort.
enableOfflineQueue: true,
});
redis.on("error", (err) => {
// Don't crash the app on Redis errors — stats are non-critical.
console.warn("⚠️ Redis error:", err.message);
});
if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = redis;
}

207
backend/src/lib/stats.ts Normal file
View File

@@ -0,0 +1,207 @@
import { redis } from "./redis";
import { prisma } from "./prisma";
/**
* XIP live stats.
*
* Two kinds of metrics:
* - PERSISTENT totals, stored in Redis (survive restarts): messages, replies,
* characters sent, letters typed (even if never sent), unique IPs, longest message.
* - LIVE metrics, kept in process memory (sliding windows): letters/sec, messages/min.
*
* The number of connected tabs and the "currently typing" count are owned by the
* realtime module and injected when building a snapshot.
*/
const K = {
messages: "xip:stat:messages",
replies: "xip:stat:replies",
charsSent: "xip:stat:chars_sent",
lettersTyped: "xip:stat:letters_typed",
longest: "xip:stat:longest",
ips: "xip:hll:ips",
initialized: "xip:stat:initialized",
creditsSpent: "xip:money:credits_spent", // centi-credits spent (set by wallet/catalog)
impressionsTotal: "xip:money:impressions_total", // ad impressions (set by lib/ads)
} as const;
// Satirical CPM: "€" earned per 1000 ad impressions.
const FAKE_CPM = 12.5;
// ── Sliding-window live metrics (per process) ──────────────────────────────
const LETTERS_WINDOW_MS = 4000; // smoothing window for letters/sec
const MSGS_WINDOW_MS = 60000; // messages per minute
let letterEvents: { ts: number; n: number }[] = [];
let messageEvents: number[] = [];
function prune(now: number): void {
letterEvents = letterEvents.filter((e) => now - e.ts <= LETTERS_WINDOW_MS);
messageEvents = messageEvents.filter((ts) => now - ts <= MSGS_WINDOW_MS);
}
export function getLettersPerSec(): number {
const now = Date.now();
prune(now);
const total = letterEvents.reduce((sum, e) => sum + e.n, 0);
return total / (LETTERS_WINDOW_MS / 1000);
}
export function getMsgsPerMin(): number {
const now = Date.now();
prune(now);
return messageEvents.length;
}
// ── First-boot backfill ─────────────────────────────────────────────────────
/**
* Seed the persistent counters from the database the first time the server runs
* (guarded by a Redis sentinel, so it's a no-op on hot reloads / restarts).
* Without this, totals would show 0 while seeded messages are already visible.
* letters_typed is intentionally NOT backfilled — it has no DB source.
*/
export async function initStats(): Promise<void> {
const first = await redis.set(K.initialized, "1", "NX").catch(() => null);
if (first !== "OK") return; // already initialized
try {
const rows = await prisma.$queryRaw<
{ messages: bigint; replies: bigint; chars: bigint; longest: bigint }[]
>`
SELECT
COUNT(*) AS messages,
COUNT(*) FILTER (WHERE "parentId" IS NOT NULL) AS replies,
COALESCE(SUM(LENGTH(content)), 0) AS chars,
COALESCE(MAX(LENGTH(content)), 0) AS longest
FROM messages
`;
const r = rows[0];
if (r) {
const pipe = redis.pipeline();
pipe.set(K.messages, String(Number(r.messages)));
pipe.set(K.replies, String(Number(r.replies)));
pipe.set(K.charsSent, String(Number(r.chars)));
pipe.set(K.longest, String(Number(r.longest)));
await pipe.exec();
}
const ips = await prisma.message.findMany({
distinct: ["authorIp"],
select: { authorIp: true },
});
if (ips.length > 0) {
await redis.pfadd(K.ips, ...ips.map((m) => m.authorIp));
}
console.log("📊 Stats backfilled from database.");
} catch (err) {
// Non-fatal: release the sentinel so a later boot can retry.
await redis.del(K.initialized).catch(() => {});
console.warn("⚠️ Stats backfill failed:", (err as Error).message);
}
}
// ── Mutations ──────────────────────────────────────────────────────────────
/** Record a freshly created message (top-level or reply). */
export async function recordMessage(
contentLength: number,
isReply: boolean
): Promise<void> {
messageEvents.push(Date.now());
const pipe = redis.pipeline();
pipe.incr(K.messages);
pipe.incrby(K.charsSent, contentLength);
if (isReply) pipe.incr(K.replies);
// Track longest message (read-modify-write is fine; contention is negligible).
pipe.get(K.longest);
const res = await pipe.exec().catch(() => null);
if (res) {
const current = Number(res[res.length - 1]?.[1] ?? 0);
if (contentLength > current) {
await redis.set(K.longest, String(contentLength)).catch(() => {});
}
}
}
/** Record letters typed (sent or not). Feeds both the persistent total and letters/sec. */
export async function recordLettersTyped(delta: number): Promise<void> {
if (!Number.isFinite(delta) || delta <= 0) return;
const n = Math.min(delta, 1000); // guard against bogus client payloads
letterEvents.push({ ts: Date.now(), n });
await redis.incrby(K.lettersTyped, n).catch(() => {});
}
/** Register an IP in the HyperLogLog of unique visitors. */
export async function recordIp(ip: string): Promise<void> {
if (!ip) return;
await redis.pfadd(K.ips, ip).catch(() => {});
}
// ── Snapshot ─────────────────────────────────────────────────────────────
export interface StatsSnapshot {
// live
connectedTabs: number;
typingNow: number;
lettersPerSec: number;
msgsPerMin: number;
// totals
messages: number;
replies: number;
charsSent: number;
lettersTyped: number;
uniqueIps: number;
longestMsg: number;
// derived
abandonRate: number; // % of typed letters that were never sent
avgLength: number; // average sent-message length
moneyExtorted: number; // fake "€": impressions×CPM + credits spent
}
export async function buildSnapshot(live: {
connectedTabs: number;
typingNow: number;
}): Promise<StatsSnapshot> {
const [messages, replies, charsSent, lettersTyped, longest, uniqueIps, creditsSpent, impressions] =
await Promise.all([
redis.get(K.messages).catch(() => "0"),
redis.get(K.replies).catch(() => "0"),
redis.get(K.charsSent).catch(() => "0"),
redis.get(K.lettersTyped).catch(() => "0"),
redis.get(K.longest).catch(() => "0"),
redis.pfcount(K.ips).catch(() => 0),
redis.get(K.creditsSpent).catch(() => "0"),
redis.get(K.impressionsTotal).catch(() => "0"),
]);
const nMessages = Number(messages ?? 0);
const nCharsSent = Number(charsSent ?? 0);
const nLettersTyped = Number(lettersTyped ?? 0);
const abandonRate =
nLettersTyped > 0
? Math.max(0, Math.min(100, ((nLettersTyped - nCharsSent) / nLettersTyped) * 100))
: 0;
const avgLength = nMessages > 0 ? nCharsSent / nMessages : 0;
// Fake revenue: ad impressions × CPM + credits spent (centi-credits → "€").
const moneyExtorted =
(Number(impressions ?? 0) / 1000) * FAKE_CPM + Number(creditsSpent ?? 0) / 100;
return {
connectedTabs: live.connectedTabs,
typingNow: live.typingNow,
lettersPerSec: getLettersPerSec(),
msgsPerMin: getMsgsPerMin(),
messages: nMessages,
replies: Number(replies ?? 0),
charsSent: nCharsSent,
lettersTyped: nLettersTyped,
uniqueIps: Number(uniqueIps ?? 0),
longestMsg: Number(longest ?? 0),
abandonRate,
avgLength,
moneyExtorted,
};
}

View File

@@ -0,0 +1,48 @@
import { mkdir } from "node:fs/promises";
import { resolve, extname } from "node:path";
/**
* Filesystem storage for uploads, under backend/uploads/.
* Files are stored under a UUID-prefixed name so a malicious client filename
* can never traverse paths or overwrite another file. The raw bytes are never
* executed server-side — we only ever read them back to serve downloads.
*/
const UPLOADS_DIR = resolve(import.meta.dir, "../../uploads");
let ensured = false;
async function ensureDir(): Promise<void> {
if (ensured) return;
await mkdir(UPLOADS_DIR, { recursive: true });
ensured = true;
}
/** Keep only a safe, short suffix of the original name for readability. */
function safeSuffix(filename: string): string {
const ext = extname(filename).slice(0, 12).replace(/[^a-zA-Z0-9.]/g, "");
return ext || "";
}
export interface StoredFile {
storagePath: string; // relative name under uploads/
absolutePath: string;
}
/** Persist a File/Blob, returning its storage path. id should be a fresh uuid. */
export async function storeFile(id: string, file: File): Promise<StoredFile> {
await ensureDir();
const name = `${id}${safeSuffix(file.name)}`;
const absolutePath = resolve(UPLOADS_DIR, name);
// Extra guard: the resolved path must stay inside UPLOADS_DIR.
if (!absolutePath.startsWith(UPLOADS_DIR)) {
throw new Error("Invalid storage path");
}
await Bun.write(absolutePath, file);
return { storagePath: name, absolutePath };
}
export function absolutePathFor(storagePath: string): string {
const abs = resolve(UPLOADS_DIR, storagePath);
if (!abs.startsWith(UPLOADS_DIR)) throw new Error("Invalid storage path");
return abs;
}

127
backend/src/lib/wallet.ts Normal file
View File

@@ -0,0 +1,127 @@
import { prisma } from "./prisma";
import { redis } from "./redis";
import { isLocalhost } from "./ip";
/**
* Wallet engine — fictional "crédits XIP", keyed on IP (no accounts).
*
* Amounts are integer CENTI-CREDITS to avoid float drift (display divides by 100).
* So 9.99 "crédits" is stored as 999.
*
* `spend()` is the single choke point for every paid action: it enforces the
* balance and is the one place the localhost "free mode" bypass lives.
*/
// Starting grant on first wallet touch, and the free top-up button amount.
export const SIGNUP_GRANT = 0;
export const TOPUP_AMOUNT = 5000; // 50.00 crédits per free top-up
// Sentinel reported as the balance for localhost (rendered as "∞" by the UI).
export const INFINITE = Number.MAX_SAFE_INTEGER;
// Redis keys (mirror + global money counter).
const walletKey = (ip: string) => `xip:wallet:${ip}`;
const CREDITS_SPENT = "xip:money:credits_spent";
export interface WalletView {
ip: string;
balance: number; // centi-credits (or INFINITE for free mode)
freeMode: boolean;
}
/** Lazily create the wallet row (with the signup grant) the first time we touch an IP. */
export async function ensureWallet(ip: string): Promise<void> {
await prisma.wallet
.upsert({
where: { ip },
create: { ip, balance: SIGNUP_GRANT },
update: {},
})
.catch(() => {});
}
export async function getWallet(ip: string): Promise<WalletView> {
if (isLocalhost(ip)) return { ip, balance: INFINITE, freeMode: true };
await ensureWallet(ip);
const w = await prisma.wallet.findUnique({ where: { ip } }).catch(() => null);
const balance = w?.balance ?? 0;
void redis.set(walletKey(ip), String(balance)).catch(() => {});
return { ip, balance, freeMode: false };
}
/** Free, instant, satirical top-up. No-op for localhost (already infinite). */
export async function topUp(ip: string, amount = TOPUP_AMOUNT): Promise<WalletView> {
if (isLocalhost(ip)) return { ip, balance: INFINITE, freeMode: true };
await ensureWallet(ip);
const w = await prisma.wallet.update({
where: { ip },
data: { balance: { increment: amount } },
});
await prisma.purchase
.create({ data: { ip, type: "topup", amount } })
.catch(() => {});
void redis.set(walletKey(ip), String(w.balance)).catch(() => {});
return { ip, balance: w.balance, freeMode: false };
}
export class InsufficientCreditsError extends Error {
constructor() {
super("Crédits insuffisants");
this.name = "InsufficientCreditsError";
}
}
/**
* Atomically spend credits. Returns the new balance.
* - localhost => free mode: records nothing, returns INFINITE.
* - otherwise: transactional re-read + guard + decrement + ledger row.
* Throws InsufficientCreditsError if the balance can't cover `amount`.
*/
export async function spend(
ip: string,
amount: number,
reason: string,
meta?: Record<string, unknown>
): Promise<number> {
if (isLocalhost(ip)) return INFINITE;
if (amount <= 0) {
// Free item — still record the (zero) purchase for history, no balance change.
const w = await getWallet(ip);
await prisma.purchase
.create({
data: { ip, type: "purchase", amount: 0, productId: reason, metaJson: meta ? JSON.stringify(meta) : null },
})
.catch(() => {});
return w.balance;
}
const newBalance = await prisma.$transaction(async (tx) => {
await tx.wallet.upsert({
where: { ip },
create: { ip, balance: SIGNUP_GRANT },
update: {},
});
const w = await tx.wallet.findUnique({ where: { ip } });
const current = w?.balance ?? 0;
if (current < amount) throw new InsufficientCreditsError();
const updated = await tx.wallet.update({
where: { ip },
data: { balance: { decrement: amount } },
});
await tx.purchase.create({
data: {
ip,
type: "purchase",
amount: -amount,
productId: reason,
metaJson: meta ? JSON.stringify(meta) : null,
},
});
return updated.balance;
});
// Mirror to Redis + bump the global "credits spent" money counter.
void redis.set(walletKey(ip), String(newBalance)).catch(() => {});
void redis.incrby(CREDITS_SPENT, amount).catch(() => {});
return newBalance;
}

136
backend/src/realtime.ts Normal file
View File

@@ -0,0 +1,136 @@
import { createBunWebSocket } from "hono/bun";
import type { WSContext } from "hono/ws";
import { buildSnapshot, recordLettersTyped } from "./lib/stats";
import { getClientIp } from "./lib/ip";
/**
* Realtime hub: one WebSocket connection = one open tab.
*
* - Broadcasts a throttled stats snapshot to every tab.
* - Broadcasts newly created messages so feeds update without polling.
* - Tracks "currently typing" presence and feeds the global letters-typed counter.
* - Knows each socket's client IP, so it can push wallet/perks frames to just
* that IP's tabs (broadcastToIp) or to everyone (broadcast).
*
* The Hono Bun adapter calls the events factory with the request Context, so we
* derive the IP once per connection in the factory and stash it in ClientState.
*/
const { upgradeWebSocket, websocket } = createBunWebSocket();
interface ClientState {
lastTypingAt: number;
ip: string;
}
const clients = new Map<WSContext, ClientState>();
const TYPING_TTL_MS = 2500; // a tab counts as "typing" for this long after a keystroke
const BROADCAST_MIN_INTERVAL_MS = 250; // throttle: at most one stats frame this often
function countTyping(now: number): number {
let n = 0;
for (const s of clients.values()) {
if (now - s.lastTypingAt <= TYPING_TTL_MS) n++;
}
return n;
}
function send(ws: WSContext, payload: string): void {
// readyState 1 === OPEN
if (ws.readyState === 1) {
try {
ws.send(payload);
} catch {
/* ignore broken pipe */
}
}
}
// ── Throttled stats broadcast ──────────────────────────────────────────────
let broadcastScheduled = false;
let lastBroadcastAt = 0;
async function flushStats(): Promise<void> {
broadcastScheduled = false;
lastBroadcastAt = Date.now();
if (clients.size === 0) return;
const snapshot = await buildSnapshot({
connectedTabs: clients.size,
typingNow: countTyping(Date.now()),
});
const payload = JSON.stringify({ type: "stats", data: snapshot });
for (const ws of clients.keys()) send(ws, payload);
}
function scheduleStats(): void {
if (broadcastScheduled) return;
broadcastScheduled = true;
const wait = Math.max(0, BROADCAST_MIN_INTERVAL_MS - (Date.now() - lastBroadcastAt));
setTimeout(() => {
void flushStats();
}, wait);
}
// Periodic tick so time-decaying metrics (letters/sec, typing expiry, msgs/min)
// keep updating even when nobody is interacting.
setInterval(() => {
if (clients.size > 0) void flushStats();
}, 1000);
/** Send an arbitrary frame to every connected tab. */
export function broadcast(payload: object): void {
const str = JSON.stringify(payload);
for (const ws of clients.keys()) send(ws, str);
}
/** Send a frame only to the tabs belonging to one IP (e.g. wallet updates). */
export function broadcastToIp(ip: string, payload: object): void {
const str = JSON.stringify(payload);
for (const [ws, state] of clients) {
if (state.ip === ip) send(ws, str);
}
}
/** Push a freshly created message to every connected tab. */
export function broadcastNewMessage(message: unknown): void {
broadcast({ type: "message", data: message });
scheduleStats(); // totals changed too
}
/** Hono route handler for GET /ws. The factory receives the request Context. */
export const wsHandler = upgradeWebSocket((c) => {
const ip = getClientIp(c);
return {
onOpen(_evt, ws) {
clients.set(ws, { lastTypingAt: 0, ip });
scheduleStats();
},
onMessage(evt, ws) {
let msg: { type?: string; delta?: number } | null = null;
try {
msg = JSON.parse(typeof evt.data === "string" ? evt.data : "{}");
} catch {
return;
}
if (!msg || typeof msg !== "object") return;
if (msg.type === "typing") {
const state = clients.get(ws);
if (state) state.lastTypingAt = Date.now();
const delta = Number(msg.delta) || 0;
if (delta > 0) void recordLettersTyped(delta);
scheduleStats();
}
},
onClose(_evt, ws) {
clients.delete(ws);
scheduleStats();
},
onError(_evt, ws) {
clients.delete(ws);
},
};
});
export { websocket };

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;