From cf239ab95f298b80328e6968d6ac7f673fb61468 Mon Sep 17 00:00:00 2001 From: kerboul Date: Sat, 30 May 2026 22:47:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20marketplace,=20=C3=A9conomie=20=C3=A0?= =?UTF-8?q?=20cr=C3=A9dits,=20perks=20temps=20r=C3=A9el=20&=20pubs=20r?= =?UTF-8?q?=C3=A9elles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/.gitignore | 1 + .../migration.sql | 112 +++++++ backend/prisma/schema.prisma | 120 ++++++- backend/prisma/seed.ts | 202 +++++++++++- backend/src/index.ts | 33 ++ backend/src/lib/ads.ts | 71 ++++ backend/src/lib/catalog.ts | 308 ++++++++++++++++++ backend/src/lib/ip.ts | 38 +++ backend/src/lib/perks.ts | 111 +++++++ backend/src/lib/redis.ts | 23 ++ backend/src/lib/stats.ts | 207 ++++++++++++ backend/src/lib/storage.ts | 48 +++ backend/src/lib/wallet.ts | 127 ++++++++ backend/src/realtime.ts | 136 ++++++++ backend/src/routes/ads.ts | 40 +++ backend/src/routes/alert.ts | 67 ++++ backend/src/routes/messages.ts | 100 +++++- backend/src/routes/perks.ts | 14 + backend/src/routes/shop.ts | 84 +++++ backend/src/routes/uploads.ts | 93 ++++++ backend/src/routes/wallet.ts | 22 ++ docker-compose.yml | 4 + frontend/src/components/AdBand.vue | 84 ++--- frontend/src/components/AnimatedNumber.vue | 50 +++ frontend/src/components/ChatHeader.vue | 60 +++- frontend/src/components/InlineCasinoAd.vue | 35 +- frontend/src/components/MenuToggle.vue | 49 --- .../src/components/MessageAttachments.vue | 80 +++++ frontend/src/components/MessageItem.vue | 109 +++++-- frontend/src/components/MessageList.vue | 8 +- frontend/src/components/RichContent.vue | 85 +++++ frontend/src/components/StatsTicker.vue | 220 +++++++++++++ frontend/src/components/shop/ProductCard.vue | 296 +++++++++++++++++ frontend/src/composables/ipColor.ts | 18 + frontend/src/composables/useAds.ts | 67 ++++ frontend/src/composables/useAlert.ts | 86 +++++ frontend/src/composables/useAttachments.ts | 43 +++ frontend/src/composables/useMessages.ts | 160 ++++++++- frontend/src/composables/usePerks.ts | 41 +++ frontend/src/composables/useRealtime.ts | 125 +++++++ frontend/src/composables/useShop.ts | 123 +++++++ frontend/src/composables/useWallet.ts | 72 ++++ frontend/src/main.ts | 7 +- frontend/src/views/HomePage.vue | 280 ++++++++++++++-- frontend/src/views/ShopPage.vue | 212 ++++++++++++ frontend/vite.config.ts | 7 + 46 files changed, 4080 insertions(+), 198 deletions(-) create mode 100644 backend/.gitignore create mode 100644 backend/prisma/migrations/20260530194607_economy_ads_attachments_rich/migration.sql create mode 100644 backend/src/lib/ads.ts create mode 100644 backend/src/lib/catalog.ts create mode 100644 backend/src/lib/ip.ts create mode 100644 backend/src/lib/perks.ts create mode 100644 backend/src/lib/redis.ts create mode 100644 backend/src/lib/stats.ts create mode 100644 backend/src/lib/storage.ts create mode 100644 backend/src/lib/wallet.ts create mode 100644 backend/src/realtime.ts create mode 100644 backend/src/routes/ads.ts create mode 100644 backend/src/routes/alert.ts create mode 100644 backend/src/routes/perks.ts create mode 100644 backend/src/routes/shop.ts create mode 100644 backend/src/routes/uploads.ts create mode 100644 backend/src/routes/wallet.ts create mode 100644 frontend/src/components/AnimatedNumber.vue delete mode 100644 frontend/src/components/MenuToggle.vue create mode 100644 frontend/src/components/MessageAttachments.vue create mode 100644 frontend/src/components/RichContent.vue create mode 100644 frontend/src/components/StatsTicker.vue create mode 100644 frontend/src/components/shop/ProductCard.vue create mode 100644 frontend/src/composables/useAds.ts create mode 100644 frontend/src/composables/useAlert.ts create mode 100644 frontend/src/composables/useAttachments.ts create mode 100644 frontend/src/composables/usePerks.ts create mode 100644 frontend/src/composables/useRealtime.ts create mode 100644 frontend/src/composables/useShop.ts create mode 100644 frontend/src/composables/useWallet.ts create mode 100644 frontend/src/views/ShopPage.vue diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8fc0d80 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +uploads/ diff --git a/backend/prisma/migrations/20260530194607_economy_ads_attachments_rich/migration.sql b/backend/prisma/migrations/20260530194607_economy_ads_attachments_rich/migration.sql new file mode 100644 index 0000000..76ed1c5 --- /dev/null +++ b/backend/prisma/migrations/20260530194607_economy_ads_attachments_rich/migration.sql @@ -0,0 +1,112 @@ +-- AlterTable +ALTER TABLE "messages" ADD COLUMN "richContent" TEXT, +ADD COLUMN "richMode" TEXT NOT NULL DEFAULT 'none'; + +-- CreateTable +CREATE TABLE "wallets" ( + "ip" TEXT NOT NULL, + "balance" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "wallets_pkey" PRIMARY KEY ("ip") +); + +-- CreateTable +CREATE TABLE "products" ( + "id" TEXT NOT NULL, + "category" TEXT NOT NULL, + "name" TEXT NOT NULL, + "subtitle" TEXT, + "kind" TEXT NOT NULL, + "basePrice" INTEGER NOT NULL, + "promoPrice" INTEGER, + "badge" TEXT, + "stockLimit" INTEGER, + "stockSold" INTEGER NOT NULL DEFAULT 0, + "active" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "metaJson" TEXT, + + CONSTRAINT "products_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "purchases" ( + "id" TEXT NOT NULL, + "ip" TEXT NOT NULL, + "productId" TEXT, + "type" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "metaJson" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "purchases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "entitlements" ( + "id" TEXT NOT NULL, + "ip" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "expiresAt" TIMESTAMP(3), + "metaJson" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ads" ( + "id" TEXT NOT NULL, + "brand" TEXT NOT NULL, + "subtitle" TEXT, + "url" TEXT, + "cta" TEXT, + "icon" TEXT, + "tone" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "weight" INTEGER NOT NULL DEFAULT 1, + "active" BOOLEAN NOT NULL DEFAULT true, + "ownerIp" TEXT, + "format" TEXT, + "imageUrl" TEXT, + "expiresAt" TIMESTAMP(3), + "impressions" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ads_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "attachments" ( + "id" TEXT NOT NULL, + "messageId" TEXT, + "ip" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "attachments_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "purchases_ip_idx" ON "purchases"("ip"); + +-- CreateIndex +CREATE INDEX "entitlements_ip_idx" ON "entitlements"("ip"); + +-- CreateIndex +CREATE INDEX "entitlements_ip_kind_idx" ON "entitlements"("ip", "kind"); + +-- CreateIndex +CREATE INDEX "ads_kind_active_idx" ON "ads"("kind", "active"); + +-- CreateIndex +CREATE INDEX "attachments_messageId_idx" ON "attachments"("messageId"); + +-- AddForeignKey +ALTER TABLE "attachments" ADD CONSTRAINT "attachments_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 89b9c66..1977d7b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -11,14 +11,120 @@ datasource db { } model Message { - id String @id @default(uuid()) - content String @db.VarChar(267) - authorIp String - createdAt DateTime @default(now()) - parentId String? + id String @id @default(uuid()) + content String @db.VarChar(267) + authorIp String + createdAt DateTime @default(now()) + parentId String? + // Rich-message tiers (paid): "none" | "htmlcss" | "js". richContent holds the raw + // markup/script, rendered ONLY inside a sandboxed iframe on the client. + richMode String @default("none") + richContent String? @db.Text - parent Message? @relation("ThreadReplies", fields: [parentId], references: [id]) - replies Message[] @relation("ThreadReplies") + parent Message? @relation("ThreadReplies", fields: [parentId], references: [id]) + replies Message[] @relation("ThreadReplies") + attachments Attachment[] @@map("messages") } + +// ── Economy: fictional "crédits XIP", keyed on IP (no accounts) ────────────── + +model Wallet { + ip String @id + balance Int @default(0) // centi-credits (9.99 "€" => 999) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("wallets") +} + +// Seeded catalogue of purchasable features (faithful to the shop mockups). +model Product { + id String @id // slug: "cadre-pub","noads","style-dore","pet","bundle-cosmetic","rich-htmlcss","rich-js","no-file-limit","audio-alert" + category String // "publicite" | "abonnements" | "cosmetiques" | "promotions" | "premium" + name String + subtitle String? + kind String // "ad-frame" | "subscription" | "ip-skin" | "pet" | "bundle" | "rich" | "unlock" | "consumable" + basePrice Int // centi-credits + promoPrice Int? + badge String? + stockLimit Int? // e.g. 50 for style-dore; null = unlimited + stockSold Int @default(0) + active Boolean @default(true) + sortOrder Int @default(0) + metaJson String? @db.Text // options config (durations, formats, pet designs, plans…) + + @@map("products") +} + +// Append-only ledger: every credit movement (top-up, purchase, grant). +model Purchase { + id String @id @default(uuid()) + ip String + productId String? + type String // "topup" | "purchase" | "grant" + amount Int // signed centi-credits: negative = spend, positive = grant + metaJson String? @db.Text + createdAt DateTime @default(now()) + + @@index([ip]) + @@map("purchases") +} + +// What an IP owns. Drives perks (skin/pets/noads), rich unlocks, ad frames, etc. +model Entitlement { + id String @id @default(uuid()) + ip String + kind String // "noads" | "style-dore" | "pet" | "rich-htmlcss" | "rich-js" | "no-file-limit" | "ad-frame" | "audio-alert" | "element-skin" + active Boolean @default(true) + expiresAt DateTime? // subscriptions / ad-frame duration; null = permanent + metaJson String? @db.Text // pet: {design,position}; ad-frame: {format,url,days}; etc. + createdAt DateTime @default(now()) + + @@index([ip]) + @@index([ip, kind]) + @@map("entitlements") +} + +// ── Real ad inventory (replaces the hardcoded AdBand / InlineCasinoAd) ─────── + +model Ad { + id String @id @default(uuid()) + brand String + subtitle String? + url String? + cta String? + icon String? + tone String // "blue" | "green" | "purple" | "casino" | "user" + kind String // "band" | "casino" + weight Int @default(1) + active Boolean @default(true) + ownerIp String? // set when bought via "Cadre de Pub" + format String? // "static" | "gif" + imageUrl String? + expiresAt DateTime? + impressions Int @default(0) + createdAt DateTime @default(now()) + + @@index([kind, active]) + @@map("ads") +} + +// ── File attachments (free <=1 Mo; paid "no-file-limit" lifts the cap) ─────── + +model Attachment { + id String @id @default(uuid()) + messageId String? + ip String + filename String + mimeType String + size Int + storagePath String + createdAt DateTime @default(now()) + + message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@index([messageId]) + @@map("attachments") +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 4b39bbd..0995327 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -2,36 +2,208 @@ import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); -async function main() { +// ── Marketplace catalogue (faithful to the shop mockups) ──────────────────── +// Prices are centi-credits (mockup € → credits): 9.99 → 999. +const PRODUCTS = [ + { + id: "cadre-pub", + category: "publicite", + name: "Cadre de Pub", + subtitle: "1 000 impressions garanties · 130×180 px · lien cliquable", + kind: "ad-frame", + basePrice: 1500, + promoPrice: 999, + badge: "-33% FLASH PROMO", + sortOrder: 10, + metaJson: JSON.stringify({ + durations: [ + { days: 7, extra: 0 }, + { days: 14, extra: 800 }, + { days: 30, extra: 2000 }, + ], + formats: [ + { id: "static", label: "Image statique", extra: 0 }, + { id: "gif", label: "GIF animé", extra: 300 }, + ], + }), + }, + { + id: "noads", + category: "abonnements", + name: "Abonnement NoAds", + subtitle: "Supprime toutes les pubs du chat", + kind: "subscription", + basePrice: 499, + badge: "POPULAIRE", + sortOrder: 20, + metaJson: JSON.stringify({ + plans: [ + { id: "monthly", label: "Mensuel", price: 499 }, + { id: "annual", label: "Annuel", price: 3999 }, + ], + }), + }, + { + id: "style-dore", + category: "cosmetiques", + name: "Style Doré", + subtitle: "Ton IP en or brillant, visible de tous", + kind: "ip-skin", + basePrice: 999, + badge: "LIMITÉ 50 ex.", + stockLimit: 50, + sortOrder: 30, + metaJson: JSON.stringify({ variant: "gold" }), + }, + { + id: "pet", + category: "cosmetiques", + name: "Pet de Nom", + subtitle: "Un petit élément décoratif autour de ton IP", + kind: "pet", + basePrice: 799, + badge: "NOUVEAU", + sortOrder: 40, + metaJson: JSON.stringify({ + designs: [ + { id: "coeur", char: "♥" }, + { id: "etoile", char: "★" }, + { id: "diamant", char: "♦" }, + { id: "trefle", char: "♣" }, + { id: "couronne", char: "♚" }, + { id: "crane", char: "☠" }, + { id: "eclair", char: "⚡" }, + { id: "fleur", char: "✿" }, + { id: "note", char: "♫" }, + { id: "feu", char: "🔥" }, + ], + positions: ["left", "right", "both"], + }), + }, + { + id: "bundle-cosmetic", + category: "promotions", + name: "Pack Cosmétique", + subtitle: "Style Doré + 1 Pet au choix", + kind: "bundle", + basePrice: 1798, + promoPrice: 1499, + badge: "-3 CR", + sortOrder: 50, + metaJson: JSON.stringify({ includes: ["style-dore", "pet"] }), + }, + { + // id == entitlement kind, so the "unlock" branch grants "element-skin". + id: "element-skin", + category: "cosmetiques", + name: "Skin d'éléments", + subtitle: "Relooke ta barre de saisie et ton bouton d'envoi", + kind: "unlock", + basePrice: 599, + sortOrder: 45, + metaJson: JSON.stringify({}), + }, + { + id: "rich-htmlcss", + category: "premium", + name: "Messages HTML / CSS", + subtitle: "Mets en forme tes messages (sans script)", + kind: "rich", + basePrice: 2999, + sortOrder: 60, + metaJson: JSON.stringify({}), + }, + { + id: "rich-js", + category: "premium", + name: "Messages JavaScript", + subtitle: "Scripts interactifs (isolés). TRÈS cher.", + kind: "rich", + basePrice: 19999, + badge: "TRÈS TRÈS CHER", + sortOrder: 70, + metaJson: JSON.stringify({}), + }, + { + id: "no-file-limit", + category: "premium", + name: "Fichiers illimités", + subtitle: "Plus de limite de 1 Mo sur tes pièces jointes", + kind: "unlock", + basePrice: 1499, + sortOrder: 80, + metaJson: JSON.stringify({}), + }, + { + id: "audio-alert", + category: "premium", + name: "Alerte audio générale", + subtitle: "Fais hurler un son chez tout le monde (cooldown)", + kind: "consumable", + basePrice: 999, + badge: "CONSOMMABLE", + sortOrder: 90, + metaJson: JSON.stringify({ cooldownMs: 60000, maxDurationMs: 5000 }), + }, +] as const; + +// ── Ad inventory (the 4 hardcoded joke ads, now real data) ────────────────── +const ADS = [ + { brand: "NOVA", subtitle: "STORE 2026", url: "https://nova-store.io", cta: "DÉCOUVRIR", icon: "🛒", tone: "blue", kind: "band", weight: 1 }, + { brand: "APEX GEAR", subtitle: "Gaming Setup", url: "https://apex-gear.com", cta: "ACHETER", icon: "🎮", tone: "green", kind: "band", weight: 1 }, + { brand: "SHIELDVPN", subtitle: "Sécurité totale", url: "https://shieldvpn.net", cta: "ESSAI GRATUIT", icon: "🔒", tone: "purple", kind: "band", weight: 1 }, + { brand: "CASINO LUCKY", subtitle: "OFFRE EXCLUSIVE · +200% · 500€ max", url: "https://casino-lucky.bet", cta: "JOUER MAINTENANT", icon: "♠", tone: "casino", kind: "casino", weight: 1 }, +] as const; + +async function seedProducts() { + for (const p of PRODUCTS) { + await prisma.product.upsert({ + where: { id: p.id }, + create: p as any, + update: p as any, + }); + } + console.log(`✅ ${PRODUCTS.length} produits upsertés.`); +} + +async function seedAds() { + for (const a of ADS) { + // Idempotent on brand: only seed the canonical (non-user) ads once. + const existing = await prisma.ad.findFirst({ where: { brand: a.brand, ownerIp: null } }); + if (existing) { + await prisma.ad.update({ where: { id: existing.id }, data: a as any }); + } else { + await prisma.ad.create({ data: a as any }); + } + } + console.log(`✅ ${ADS.length} pubs upsertées.`); +} + +async function seedMessages() { const count = await prisma.message.count(); if (count > 0) { - console.log("⏭️ Database already seeded, skipping."); + console.log("⏭️ Messages déjà présents, seed messages ignoré."); return; } - const root1 = await prisma.message.create({ data: { content: "Bienvenue sur XIP — le réseau social sans filtre ni compte.", authorIp: "1.2.3.4", }, }); - await prisma.message.create({ - data: { - content: "Pas de compte, ton IP c'est toi.", - authorIp: "5.6.7.8", - }, + data: { content: "Pas de compte, ton IP c'est toi.", authorIp: "5.6.7.8" }, }); - await prisma.message.create({ - data: { - content: "Réponse au premier message !", - authorIp: "9.10.11.12", - parentId: root1.id, - }, + data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id }, }); + console.log("✅ 3 messages de démo créés."); +} - console.log("✅ Database seeded with 3 messages."); +async function main() { + await seedProducts(); + await seedAds(); + await seedMessages(); } main() diff --git a/backend/src/index.ts b/backend/src/index.ts index 86b28d5..1fe7a27 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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, }; diff --git a/backend/src/lib/ads.ts b/backend/src/lib/ads.ts new file mode 100644 index 0000000..899dcc2 --- /dev/null +++ b/backend/src/lib/ads.ts @@ -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: + 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 { + 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 { + 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 { + 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 { + 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(() => {}); + } +} diff --git a/backend/src/lib/catalog.ts b/backend/src/lib/catalog.ts new file mode 100644 index 0000000..4ff25f5 --- /dev/null +++ b/backend/src/lib/catalog.ts @@ -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 { + 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); +} diff --git a/backend/src/lib/ip.ts b/backend/src/lib/ip.ts new file mode 100644 index 0000000..05b1ac2 --- /dev/null +++ b/backend/src/lib/ip.ts @@ -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.") + ); +} diff --git a/backend/src/lib/perks.ts b/backend/src/lib/perks.ts new file mode 100644 index 0000000..3212bc9 --- /dev/null +++ b/backend/src/lib/perks.ts @@ -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 { + await redis.del(perksKey(ip)).catch(() => {}); +} + +/** Compute perks for one IP from its active, non-expired entitlements. */ +export async function getPerksForIp(ip: string): Promise { + // 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> { + const uniq = [...new Set(ips.filter(Boolean))]; + const out: Record = {}; + await Promise.all( + uniq.map(async (ip) => { + out[ip] = await getPerksForIp(ip); + }) + ); + return out; +} diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts new file mode 100644 index 0000000..dfca1f9 --- /dev/null +++ b/backend/src/lib/redis.ts @@ -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; +} diff --git a/backend/src/lib/stats.ts b/backend/src/lib/stats.ts new file mode 100644 index 0000000..44082e6 --- /dev/null +++ b/backend/src/lib/stats.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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, + }; +} diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts new file mode 100644 index 0000000..02ff407 --- /dev/null +++ b/backend/src/lib/storage.ts @@ -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 { + 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 { + 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; +} diff --git a/backend/src/lib/wallet.ts b/backend/src/lib/wallet.ts new file mode 100644 index 0000000..ffac90a --- /dev/null +++ b/backend/src/lib/wallet.ts @@ -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 { + await prisma.wallet + .upsert({ + where: { ip }, + create: { ip, balance: SIGNUP_GRANT }, + update: {}, + }) + .catch(() => {}); +} + +export async function getWallet(ip: string): Promise { + 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 { + 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 +): Promise { + 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; +} diff --git a/backend/src/realtime.ts b/backend/src/realtime.ts new file mode 100644 index 0000000..81a84d6 --- /dev/null +++ b/backend/src/realtime.ts @@ -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(); + +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 { + 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 }; diff --git a/backend/src/routes/ads.ts b/backend/src/routes/ads.ts new file mode 100644 index 0000000..2790ae4 --- /dev/null +++ b/backend/src/routes/ads.ts @@ -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; diff --git a/backend/src/routes/alert.ts b/backend/src/routes/alert.ts new file mode 100644 index 0000000..35d8fd4 --- /dev/null +++ b/backend/src/routes/alert.ts @@ -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; diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 50e6966..887fd54 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -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 { + 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(); + 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; diff --git a/backend/src/routes/perks.ts b/backend/src/routes/perks.ts new file mode 100644 index 0000000..a3cc9c9 --- /dev/null +++ b/backend/src/routes/perks.ts @@ -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; diff --git a/backend/src/routes/shop.ts b/backend/src/routes/shop.ts new file mode 100644 index 0000000..21c832c --- /dev/null +++ b/backend/src/routes/shop.ts @@ -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; diff --git a/backend/src/routes/uploads.ts b/backend/src/routes/uploads.ts new file mode 100644 index 0000000..b751d77 --- /dev/null +++ b/backend/src/routes/uploads.ts @@ -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 { + 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; + 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 = { + // 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; diff --git a/backend/src/routes/wallet.ts b/backend/src/routes/wallet.ts new file mode 100644 index 0000000..34230f9 --- /dev/null +++ b/backend/src/routes/wallet.ts @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml index 622a1b1..3f2b146 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,11 @@ services: redis: image: redis:7 restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] ports: - "6379:6379" + volumes: + - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -29,3 +32,4 @@ services: volumes: postgres_data: + redis_data: diff --git a/frontend/src/components/AdBand.vue b/frontend/src/components/AdBand.vue index f540445..dd67bbf 100644 --- a/frontend/src/components/AdBand.vue +++ b/frontend/src/components/AdBand.vue @@ -1,49 +1,48 @@ - + + + diff --git a/frontend/src/components/AnimatedNumber.vue b/frontend/src/components/AnimatedNumber.vue new file mode 100644 index 0000000..47ce285 --- /dev/null +++ b/frontend/src/components/AnimatedNumber.vue @@ -0,0 +1,50 @@ + + + + diff --git a/frontend/src/components/ChatHeader.vue b/frontend/src/components/ChatHeader.vue index ee498c8..fcab204 100644 --- a/frontend/src/components/ChatHeader.vue +++ b/frontend/src/components/ChatHeader.vue @@ -7,12 +7,26 @@