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:
71
backend/src/lib/ads.ts
Normal file
71
backend/src/lib/ads.ts
Normal 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
308
backend/src/lib/catalog.ts
Normal 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
38
backend/src/lib/ip.ts
Normal 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
111
backend/src/lib/perks.ts
Normal 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
23
backend/src/lib/redis.ts
Normal 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
207
backend/src/lib/stats.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
backend/src/lib/storage.ts
Normal file
48
backend/src/lib/storage.ts
Normal 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
127
backend/src/lib/wallet.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user