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

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;