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

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;
}