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:
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
uploads/
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
71
backend/src/lib/ads.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { redis } from "./redis";
|
||||
|
||||
/**
|
||||
* Ad inventory access + impression counting.
|
||||
*
|
||||
* Active, non-expired ads are served by kind ("band" | "casino"). Impressions
|
||||
* are counted cheaply in Redis (xip:ad:impressions:<id> + a global total) and
|
||||
* periodically reconciled into Ad.impressions for durability.
|
||||
*/
|
||||
|
||||
const IMP_PREFIX = "xip:ad:impressions:";
|
||||
const IMP_TOTAL = "xip:money:impressions_total";
|
||||
|
||||
export async function listActiveAds(kind: "band" | "casino") {
|
||||
const now = new Date();
|
||||
const ads = await prisma.ad.findMany({
|
||||
where: { kind, active: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return ads.filter((a) => !a.expiresAt || a.expiresAt >= now);
|
||||
}
|
||||
|
||||
/** Record N impressions for a set of ad ids (best-effort, Redis only). */
|
||||
export async function recordImpressions(ids: string[]): Promise<void> {
|
||||
if (!ids.length) return;
|
||||
const pipe = redis.pipeline();
|
||||
for (const id of ids) pipe.incr(IMP_PREFIX + id);
|
||||
pipe.incrby(IMP_TOTAL, ids.length);
|
||||
await pipe.exec().catch(() => {});
|
||||
}
|
||||
|
||||
/** Total impressions across all ads (for the money counter). */
|
||||
export async function getImpressionTotal(): Promise<number> {
|
||||
const v = await redis.get(IMP_TOTAL).catch(() => "0");
|
||||
return Number(v ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically fold the Redis per-ad impression counters into the DB so the
|
||||
* Ad.impressions column stays roughly current (and survives a Redis flush).
|
||||
*/
|
||||
export async function reconcileImpressions(): Promise<void> {
|
||||
try {
|
||||
const ads = await prisma.ad.findMany({ select: { id: true } });
|
||||
for (const { id } of ads) {
|
||||
const key = IMP_PREFIX + id;
|
||||
const v = await redis.get(key).catch(() => null);
|
||||
const n = Number(v ?? 0);
|
||||
if (n > 0) {
|
||||
await prisma.ad.update({ where: { id }, data: { impressions: n } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Backfill the Redis impression total from the DB on first boot. */
|
||||
export async function initImpressionTotal(): Promise<void> {
|
||||
const exists = await redis.exists(IMP_TOTAL).catch(() => 0);
|
||||
if (exists) return;
|
||||
const agg = await prisma.ad.aggregate({ _sum: { impressions: true } }).catch(() => null);
|
||||
const sum = agg?._sum.impressions ?? 0;
|
||||
if (sum > 0) await redis.set(IMP_TOTAL, String(sum)).catch(() => {});
|
||||
// Also seed per-ad keys so reconcile doesn't clobber DB values with 0.
|
||||
const ads = await prisma.ad.findMany({ select: { id: true, impressions: true } }).catch(() => []);
|
||||
for (const a of ads) {
|
||||
if (a.impressions > 0) await redis.set(IMP_PREFIX + a.id, String(a.impressions)).catch(() => {});
|
||||
}
|
||||
}
|
||||
308
backend/src/lib/catalog.ts
Normal file
308
backend/src/lib/catalog.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { spend, getWallet, InsufficientCreditsError } from "./wallet";
|
||||
import { isLocalhost } from "./ip";
|
||||
import { invalidatePerks, getPerksForIp } from "./perks";
|
||||
|
||||
/**
|
||||
* Marketplace catalogue + purchase engine.
|
||||
*
|
||||
* Prices are centi-credits (mockup € → credits). The server is the ONLY
|
||||
* authority on price, stock, and per-IP limits — the client never decides.
|
||||
*/
|
||||
|
||||
export interface PurchaseOptions {
|
||||
plan?: "monthly" | "annual"; // subscription
|
||||
durationDays?: number; // ad-frame
|
||||
format?: "static" | "gif"; // ad-frame
|
||||
url?: string; // ad-frame destination
|
||||
petDesign?: string; // pet slug
|
||||
petChar?: string; // pet glyph
|
||||
petPosition?: "left" | "right" | "both";
|
||||
}
|
||||
|
||||
export interface PurchaseResult {
|
||||
ok: true;
|
||||
productId: string;
|
||||
pricePaid: number;
|
||||
balance: number;
|
||||
entitlementKinds: string[];
|
||||
}
|
||||
|
||||
export class PurchaseError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status = 400) {
|
||||
super(message);
|
||||
this.name = "PurchaseError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Effective unit price for a product given options (promo + add-ons). */
|
||||
function effectivePrice(product: any, options: PurchaseOptions): number {
|
||||
let price = product.promoPrice ?? product.basePrice;
|
||||
let meta: any = {};
|
||||
try {
|
||||
meta = product.metaJson ? JSON.parse(product.metaJson) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
|
||||
if (product.kind === "subscription") {
|
||||
const plan = (meta.plans ?? []).find((p: any) => p.id === (options.plan ?? "monthly"));
|
||||
if (plan) price = plan.price;
|
||||
}
|
||||
if (product.kind === "ad-frame") {
|
||||
const dur = (meta.durations ?? []).find(
|
||||
(d: any) => d.days === (options.durationDays ?? 7)
|
||||
);
|
||||
const fmt = (meta.formats ?? []).find(
|
||||
(f: any) => f.id === (options.format ?? "static")
|
||||
);
|
||||
price += (dur?.extra ?? 0) + (fmt?.extra ?? 0);
|
||||
}
|
||||
return price;
|
||||
}
|
||||
|
||||
export async function listProducts(category?: string) {
|
||||
return prisma.product.findMany({
|
||||
where: { active: true, ...(category ? { category } : {}) },
|
||||
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export function getProduct(id: string) {
|
||||
return prisma.product.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
/** All entitlements an IP owns (active), for "Mes achats". */
|
||||
export async function getEntitlements(ip: string) {
|
||||
const now = new Date();
|
||||
const rows = await prisma.entitlement.findMany({
|
||||
where: { ip, active: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now);
|
||||
}
|
||||
|
||||
async function countActiveEntitlements(ip: string, kind: string): Promise<number> {
|
||||
const now = new Date();
|
||||
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });
|
||||
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buy a product. Enforces per-IP limits + stock, spends credits atomically,
|
||||
* grants the entitlement(s). Returns the new balance and granted kinds.
|
||||
* Side-effect: caller should bust perks cache + broadcast (done in the route).
|
||||
*/
|
||||
export async function purchase(
|
||||
ip: string,
|
||||
productId: string,
|
||||
options: PurchaseOptions = {}
|
||||
): Promise<{ result: PurchaseResult; visiblePerkChanged: boolean; adCreated: boolean }> {
|
||||
const product = await getProduct(productId);
|
||||
if (!product || !product.active) throw new PurchaseError("Produit introuvable", 404);
|
||||
|
||||
const free = isLocalhost(ip);
|
||||
const price = effectivePrice(product, options);
|
||||
|
||||
// Resolve which entitlement kind(s) this grants + per-IP limit checks.
|
||||
const grants: { kind: string; expiresAt?: Date; meta?: any }[] = [];
|
||||
let visiblePerkChanged = false;
|
||||
let adCreated = false;
|
||||
|
||||
switch (product.kind) {
|
||||
case "subscription": {
|
||||
// NoAds: 1 active max.
|
||||
if ((await countActiveEntitlements(ip, "noads")) >= 1)
|
||||
throw new PurchaseError("Tu as déjà un abonnement NoAds actif", 409);
|
||||
const plan = options.plan ?? "monthly";
|
||||
const days = plan === "annual" ? 365 : 30;
|
||||
grants.push({ kind: "noads", expiresAt: new Date(Date.now() + days * DAY_MS), meta: { plan } });
|
||||
break;
|
||||
}
|
||||
case "ip-skin": {
|
||||
// Style Doré: 1 active max + global stock cap.
|
||||
if ((await countActiveEntitlements(ip, "style-dore")) >= 1)
|
||||
throw new PurchaseError("Tu possèdes déjà le Style Doré", 409);
|
||||
grants.push({ kind: "style-dore", meta: { variant: "gold" } });
|
||||
visiblePerkChanged = true;
|
||||
break;
|
||||
}
|
||||
case "pet": {
|
||||
if ((await countActiveEntitlements(ip, "pet")) >= 3)
|
||||
throw new PurchaseError("Maximum 3 pets actifs", 409);
|
||||
const char = options.petChar ?? "♥";
|
||||
grants.push({
|
||||
kind: "pet",
|
||||
meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" },
|
||||
});
|
||||
visiblePerkChanged = true;
|
||||
break;
|
||||
}
|
||||
case "ad-frame": {
|
||||
if ((await countActiveEntitlements(ip, "ad-frame")) >= 1)
|
||||
throw new PurchaseError("Tu as déjà un cadre de pub actif", 409);
|
||||
const days = options.durationDays ?? 7;
|
||||
grants.push({
|
||||
kind: "ad-frame",
|
||||
expiresAt: new Date(Date.now() + days * DAY_MS),
|
||||
meta: { format: options.format ?? "static", url: options.url ?? "", days },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rich": {
|
||||
const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss";
|
||||
if ((await countActiveEntitlements(ip, kind)) >= 1)
|
||||
throw new PurchaseError("Déjà débloqué", 409);
|
||||
grants.push({ kind });
|
||||
break;
|
||||
}
|
||||
case "unlock": {
|
||||
// no-file-limit, element-skin, etc. — slug == kind.
|
||||
if ((await countActiveEntitlements(ip, product.id)) >= 1)
|
||||
throw new PurchaseError("Déjà débloqué", 409);
|
||||
grants.push({ kind: product.id });
|
||||
if (product.id === "element-skin") visiblePerkChanged = false; // viewer-scoped
|
||||
break;
|
||||
}
|
||||
case "consumable": {
|
||||
// audio-alert: grant the entitlement once; firing is a separate action.
|
||||
if ((await countActiveEntitlements(ip, "audio-alert")) < 1) {
|
||||
grants.push({ kind: "audio-alert" });
|
||||
} else {
|
||||
// Already owned — buying again is a harmless top-up; just record it.
|
||||
grants.push({ kind: "audio-alert" });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "bundle": {
|
||||
// Cosmetic bundle: Style Doré + 1 pet.
|
||||
if ((await countActiveEntitlements(ip, "style-dore")) < 1)
|
||||
grants.push({ kind: "style-dore", meta: { variant: "gold" } });
|
||||
if ((await countActiveEntitlements(ip, "pet")) < 3) {
|
||||
const char = options.petChar ?? "★";
|
||||
grants.push({
|
||||
kind: "pet",
|
||||
meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" },
|
||||
});
|
||||
}
|
||||
if (grants.length === 0)
|
||||
throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409);
|
||||
visiblePerkChanged = true;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new PurchaseError("Type de produit non géré", 400);
|
||||
}
|
||||
|
||||
// Stock check for limited products (Style Doré). Done transactionally with the
|
||||
// spend so we can never oversell the 50-unit cap under concurrency.
|
||||
let balance = 0;
|
||||
try {
|
||||
balance = await prisma.$transaction(async (tx) => {
|
||||
if (product.stockLimit != null) {
|
||||
const fresh = await tx.product.findUnique({ where: { id: product.id } });
|
||||
if (!fresh) throw new PurchaseError("Produit introuvable", 404);
|
||||
if (fresh.stockSold >= fresh.stockLimit)
|
||||
throw new PurchaseError("Stock épuisé", 409);
|
||||
await tx.product.update({
|
||||
where: { id: product.id },
|
||||
data: { stockSold: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
// Spend (skips real deduction for localhost free mode).
|
||||
if (!free && price > 0) {
|
||||
const w = await tx.wallet.upsert({
|
||||
where: { ip },
|
||||
create: { ip, balance: 0 },
|
||||
update: {},
|
||||
});
|
||||
if (w.balance < price) throw new InsufficientCreditsError();
|
||||
const updated = await tx.wallet.update({
|
||||
where: { ip },
|
||||
data: { balance: { decrement: price } },
|
||||
});
|
||||
await tx.purchase.create({
|
||||
data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) },
|
||||
});
|
||||
// Grant entitlements inside the tx too.
|
||||
for (const g of grants) {
|
||||
await tx.entitlement.create({
|
||||
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
|
||||
});
|
||||
}
|
||||
return updated.balance;
|
||||
}
|
||||
|
||||
// Free (localhost) or zero-price: record purchase + grants, no deduction.
|
||||
await tx.purchase.create({
|
||||
data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) },
|
||||
});
|
||||
for (const g of grants) {
|
||||
await tx.entitlement.create({
|
||||
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
|
||||
});
|
||||
}
|
||||
const w = await tx.wallet.findUnique({ where: { ip } });
|
||||
return w?.balance ?? 0;
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof InsufficientCreditsError)
|
||||
throw new PurchaseError("Crédits insuffisants", 402);
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Bump the global credits-spent money counter (outside the tx; best-effort).
|
||||
if (!free && price > 0) {
|
||||
const { redis } = await import("./redis");
|
||||
void redis.incrby("xip:money:credits_spent", price).catch(() => {});
|
||||
}
|
||||
|
||||
// Ad-frame purchase => create a real Ad row that enters rotation (Phase 7).
|
||||
const adGrant = grants.find((g) => g.kind === "ad-frame");
|
||||
if (adGrant) {
|
||||
await prisma.ad
|
||||
.create({
|
||||
data: {
|
||||
brand: "VOTRE PUB",
|
||||
subtitle: "Espace acheté",
|
||||
url: adGrant.meta?.url || null,
|
||||
cta: "VOIR",
|
||||
icon: "📣",
|
||||
tone: "user",
|
||||
kind: "band",
|
||||
weight: 3,
|
||||
active: true,
|
||||
ownerIp: ip,
|
||||
format: adGrant.meta?.format ?? "static",
|
||||
expiresAt: adGrant.expiresAt ?? null,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
adCreated = true;
|
||||
}
|
||||
|
||||
const balanceView = free ? (await getWallet(ip)).balance : balance;
|
||||
|
||||
return {
|
||||
result: {
|
||||
ok: true,
|
||||
productId: product.id,
|
||||
pricePaid: free ? 0 : price,
|
||||
balance: balanceView,
|
||||
entitlementKinds: grants.map((g) => g.kind),
|
||||
},
|
||||
visiblePerkChanged,
|
||||
adCreated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Recompute + cache perks after a purchase (caller broadcasts). */
|
||||
export async function refreshPerks(ip: string) {
|
||||
await invalidatePerks(ip);
|
||||
return getPerksForIp(ip);
|
||||
}
|
||||
38
backend/src/lib/ip.ts
Normal file
38
backend/src/lib/ip.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Context } from "hono";
|
||||
import { getConnInfo } from "hono/bun";
|
||||
|
||||
/**
|
||||
* Best-effort client IP.
|
||||
* Prefer x-forwarded-for (set when behind a proxy), fall back to the raw socket
|
||||
* address from Bun. In local dev (frontend:5173 → backend:3000, no proxy) this
|
||||
* is typically 127.0.0.1 / ::1.
|
||||
*/
|
||||
export function getClientIp(c: Context): string {
|
||||
const fwd = c.req.header("x-forwarded-for");
|
||||
if (fwd) {
|
||||
const first = fwd.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
try {
|
||||
const addr = getConnInfo(c).remote.address;
|
||||
if (addr) return addr;
|
||||
} catch {
|
||||
/* getConnInfo only works under the Bun adapter */
|
||||
}
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this IP the local machine? Drives the README rule "si localhost: pas de
|
||||
* paywall (tout gratuit)". Covers IPv4 loopback, IPv6 loopback, and the
|
||||
* IPv4-mapped-IPv6 form Bun sometimes reports.
|
||||
*/
|
||||
export function isLocalhost(ip: string): boolean {
|
||||
return (
|
||||
ip === "127.0.0.1" ||
|
||||
ip === "::1" ||
|
||||
ip === "::ffff:127.0.0.1" ||
|
||||
ip === "localhost" ||
|
||||
ip.startsWith("127.")
|
||||
);
|
||||
}
|
||||
111
backend/src/lib/perks.ts
Normal file
111
backend/src/lib/perks.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { redis } from "./redis";
|
||||
|
||||
/**
|
||||
* Perks = the visible/functional consequences of an IP's active entitlements.
|
||||
*
|
||||
* - skin: 'gold' (Style Doré — everyone sees it)
|
||||
* - pets: [{char, position}] (Pets de Nom — everyone sees them)
|
||||
* - noads: true (NoAds subscription — viewer-scoped)
|
||||
* - badge: true (annual NoAds — exclusive badge)
|
||||
* - elementSkin: true (one cosmetic element variant — viewer-scoped)
|
||||
* - richHtmlcss / richJs / noFileLimit (unlocks the composer / upload gate)
|
||||
*
|
||||
* Cached in Redis (short TTL) and busted on purchase so the feed updates live.
|
||||
*/
|
||||
|
||||
export type PetPosition = "left" | "right" | "both";
|
||||
|
||||
export interface Perks {
|
||||
skin?: "gold";
|
||||
pets?: { char: string; position: PetPosition }[];
|
||||
noads?: boolean;
|
||||
badge?: boolean;
|
||||
elementSkin?: boolean;
|
||||
richHtmlcss?: boolean;
|
||||
richJs?: boolean;
|
||||
noFileLimit?: boolean;
|
||||
}
|
||||
|
||||
const perksKey = (ip: string) => `xip:perks:${ip}`;
|
||||
const TTL_SEC = 60;
|
||||
|
||||
/** Drop the cached perks for an IP. Call BEFORE broadcasting a perks change. */
|
||||
export async function invalidatePerks(ip: string): Promise<void> {
|
||||
await redis.del(perksKey(ip)).catch(() => {});
|
||||
}
|
||||
|
||||
/** Compute perks for one IP from its active, non-expired entitlements. */
|
||||
export async function getPerksForIp(ip: string): Promise<Perks> {
|
||||
// Fast path: cache.
|
||||
const cached = await redis.get(perksKey(ip)).catch(() => null);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached) as Perks;
|
||||
} catch {
|
||||
/* fall through to recompute */
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const rows = await prisma.entitlement
|
||||
.findMany({ where: { ip, active: true } })
|
||||
.catch(() => []);
|
||||
|
||||
const perks: Perks = {};
|
||||
const pets: { char: string; position: PetPosition }[] = [];
|
||||
|
||||
for (const e of rows) {
|
||||
// Skip expired (subscriptions / ad-frames).
|
||||
if (e.expiresAt && e.expiresAt < now) continue;
|
||||
let meta: any = {};
|
||||
try {
|
||||
meta = e.metaJson ? JSON.parse(e.metaJson) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
|
||||
switch (e.kind) {
|
||||
case "style-dore":
|
||||
perks.skin = "gold";
|
||||
break;
|
||||
case "pet":
|
||||
if (meta.char) pets.push({ char: meta.char, position: meta.position ?? "left" });
|
||||
break;
|
||||
case "noads":
|
||||
perks.noads = true;
|
||||
if (meta.plan === "annual") perks.badge = true;
|
||||
break;
|
||||
case "element-skin":
|
||||
perks.elementSkin = true;
|
||||
break;
|
||||
case "rich-htmlcss":
|
||||
perks.richHtmlcss = true;
|
||||
break;
|
||||
case "rich-js":
|
||||
perks.richJs = true;
|
||||
break;
|
||||
case "no-file-limit":
|
||||
perks.noFileLimit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pets.length) perks.pets = pets.slice(0, 3);
|
||||
|
||||
await redis.set(perksKey(ip), JSON.stringify(perks), "EX", TTL_SEC).catch(() => {});
|
||||
return perks;
|
||||
}
|
||||
|
||||
/** Batch perks for several IPs (used to annotate message lists). */
|
||||
export async function getPerksForIps(
|
||||
ips: string[]
|
||||
): Promise<Record<string, Perks>> {
|
||||
const uniq = [...new Set(ips.filter(Boolean))];
|
||||
const out: Record<string, Perks> = {};
|
||||
await Promise.all(
|
||||
uniq.map(async (ip) => {
|
||||
out[ip] = await getPerksForIp(ip);
|
||||
})
|
||||
);
|
||||
return out;
|
||||
}
|
||||
23
backend/src/lib/redis.ts
Normal file
23
backend/src/lib/redis.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
const globalForRedis = globalThis as unknown as { redis?: Redis };
|
||||
|
||||
const REDIS_URL = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
|
||||
|
||||
export const redis =
|
||||
globalForRedis.redis ??
|
||||
new Redis(REDIS_URL, {
|
||||
lazyConnect: false,
|
||||
maxRetriesPerRequest: 3,
|
||||
// Keep the dev server alive even if Redis hiccups; stats are best-effort.
|
||||
enableOfflineQueue: true,
|
||||
});
|
||||
|
||||
redis.on("error", (err) => {
|
||||
// Don't crash the app on Redis errors — stats are non-critical.
|
||||
console.warn("⚠️ Redis error:", err.message);
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForRedis.redis = redis;
|
||||
}
|
||||
207
backend/src/lib/stats.ts
Normal file
207
backend/src/lib/stats.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { redis } from "./redis";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
/**
|
||||
* XIP live stats.
|
||||
*
|
||||
* Two kinds of metrics:
|
||||
* - PERSISTENT totals, stored in Redis (survive restarts): messages, replies,
|
||||
* characters sent, letters typed (even if never sent), unique IPs, longest message.
|
||||
* - LIVE metrics, kept in process memory (sliding windows): letters/sec, messages/min.
|
||||
*
|
||||
* The number of connected tabs and the "currently typing" count are owned by the
|
||||
* realtime module and injected when building a snapshot.
|
||||
*/
|
||||
|
||||
const K = {
|
||||
messages: "xip:stat:messages",
|
||||
replies: "xip:stat:replies",
|
||||
charsSent: "xip:stat:chars_sent",
|
||||
lettersTyped: "xip:stat:letters_typed",
|
||||
longest: "xip:stat:longest",
|
||||
ips: "xip:hll:ips",
|
||||
initialized: "xip:stat:initialized",
|
||||
creditsSpent: "xip:money:credits_spent", // centi-credits spent (set by wallet/catalog)
|
||||
impressionsTotal: "xip:money:impressions_total", // ad impressions (set by lib/ads)
|
||||
} as const;
|
||||
|
||||
// Satirical CPM: "€" earned per 1000 ad impressions.
|
||||
const FAKE_CPM = 12.5;
|
||||
|
||||
// ── Sliding-window live metrics (per process) ──────────────────────────────
|
||||
const LETTERS_WINDOW_MS = 4000; // smoothing window for letters/sec
|
||||
const MSGS_WINDOW_MS = 60000; // messages per minute
|
||||
|
||||
let letterEvents: { ts: number; n: number }[] = [];
|
||||
let messageEvents: number[] = [];
|
||||
|
||||
function prune(now: number): void {
|
||||
letterEvents = letterEvents.filter((e) => now - e.ts <= LETTERS_WINDOW_MS);
|
||||
messageEvents = messageEvents.filter((ts) => now - ts <= MSGS_WINDOW_MS);
|
||||
}
|
||||
|
||||
export function getLettersPerSec(): number {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
const total = letterEvents.reduce((sum, e) => sum + e.n, 0);
|
||||
return total / (LETTERS_WINDOW_MS / 1000);
|
||||
}
|
||||
|
||||
export function getMsgsPerMin(): number {
|
||||
const now = Date.now();
|
||||
prune(now);
|
||||
return messageEvents.length;
|
||||
}
|
||||
|
||||
// ── First-boot backfill ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed the persistent counters from the database the first time the server runs
|
||||
* (guarded by a Redis sentinel, so it's a no-op on hot reloads / restarts).
|
||||
* Without this, totals would show 0 while seeded messages are already visible.
|
||||
* letters_typed is intentionally NOT backfilled — it has no DB source.
|
||||
*/
|
||||
export async function initStats(): Promise<void> {
|
||||
const first = await redis.set(K.initialized, "1", "NX").catch(() => null);
|
||||
if (first !== "OK") return; // already initialized
|
||||
|
||||
try {
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ messages: bigint; replies: bigint; chars: bigint; longest: bigint }[]
|
||||
>`
|
||||
SELECT
|
||||
COUNT(*) AS messages,
|
||||
COUNT(*) FILTER (WHERE "parentId" IS NOT NULL) AS replies,
|
||||
COALESCE(SUM(LENGTH(content)), 0) AS chars,
|
||||
COALESCE(MAX(LENGTH(content)), 0) AS longest
|
||||
FROM messages
|
||||
`;
|
||||
const r = rows[0];
|
||||
if (r) {
|
||||
const pipe = redis.pipeline();
|
||||
pipe.set(K.messages, String(Number(r.messages)));
|
||||
pipe.set(K.replies, String(Number(r.replies)));
|
||||
pipe.set(K.charsSent, String(Number(r.chars)));
|
||||
pipe.set(K.longest, String(Number(r.longest)));
|
||||
await pipe.exec();
|
||||
}
|
||||
|
||||
const ips = await prisma.message.findMany({
|
||||
distinct: ["authorIp"],
|
||||
select: { authorIp: true },
|
||||
});
|
||||
if (ips.length > 0) {
|
||||
await redis.pfadd(K.ips, ...ips.map((m) => m.authorIp));
|
||||
}
|
||||
console.log("📊 Stats backfilled from database.");
|
||||
} catch (err) {
|
||||
// Non-fatal: release the sentinel so a later boot can retry.
|
||||
await redis.del(K.initialized).catch(() => {});
|
||||
console.warn("⚠️ Stats backfill failed:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Record a freshly created message (top-level or reply). */
|
||||
export async function recordMessage(
|
||||
contentLength: number,
|
||||
isReply: boolean
|
||||
): Promise<void> {
|
||||
messageEvents.push(Date.now());
|
||||
const pipe = redis.pipeline();
|
||||
pipe.incr(K.messages);
|
||||
pipe.incrby(K.charsSent, contentLength);
|
||||
if (isReply) pipe.incr(K.replies);
|
||||
// Track longest message (read-modify-write is fine; contention is negligible).
|
||||
pipe.get(K.longest);
|
||||
const res = await pipe.exec().catch(() => null);
|
||||
if (res) {
|
||||
const current = Number(res[res.length - 1]?.[1] ?? 0);
|
||||
if (contentLength > current) {
|
||||
await redis.set(K.longest, String(contentLength)).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Record letters typed (sent or not). Feeds both the persistent total and letters/sec. */
|
||||
export async function recordLettersTyped(delta: number): Promise<void> {
|
||||
if (!Number.isFinite(delta) || delta <= 0) return;
|
||||
const n = Math.min(delta, 1000); // guard against bogus client payloads
|
||||
letterEvents.push({ ts: Date.now(), n });
|
||||
await redis.incrby(K.lettersTyped, n).catch(() => {});
|
||||
}
|
||||
|
||||
/** Register an IP in the HyperLogLog of unique visitors. */
|
||||
export async function recordIp(ip: string): Promise<void> {
|
||||
if (!ip) return;
|
||||
await redis.pfadd(K.ips, ip).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Snapshot ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StatsSnapshot {
|
||||
// live
|
||||
connectedTabs: number;
|
||||
typingNow: number;
|
||||
lettersPerSec: number;
|
||||
msgsPerMin: number;
|
||||
// totals
|
||||
messages: number;
|
||||
replies: number;
|
||||
charsSent: number;
|
||||
lettersTyped: number;
|
||||
uniqueIps: number;
|
||||
longestMsg: number;
|
||||
// derived
|
||||
abandonRate: number; // % of typed letters that were never sent
|
||||
avgLength: number; // average sent-message length
|
||||
moneyExtorted: number; // fake "€": impressions×CPM + credits spent
|
||||
}
|
||||
|
||||
export async function buildSnapshot(live: {
|
||||
connectedTabs: number;
|
||||
typingNow: number;
|
||||
}): Promise<StatsSnapshot> {
|
||||
const [messages, replies, charsSent, lettersTyped, longest, uniqueIps, creditsSpent, impressions] =
|
||||
await Promise.all([
|
||||
redis.get(K.messages).catch(() => "0"),
|
||||
redis.get(K.replies).catch(() => "0"),
|
||||
redis.get(K.charsSent).catch(() => "0"),
|
||||
redis.get(K.lettersTyped).catch(() => "0"),
|
||||
redis.get(K.longest).catch(() => "0"),
|
||||
redis.pfcount(K.ips).catch(() => 0),
|
||||
redis.get(K.creditsSpent).catch(() => "0"),
|
||||
redis.get(K.impressionsTotal).catch(() => "0"),
|
||||
]);
|
||||
|
||||
const nMessages = Number(messages ?? 0);
|
||||
const nCharsSent = Number(charsSent ?? 0);
|
||||
const nLettersTyped = Number(lettersTyped ?? 0);
|
||||
|
||||
const abandonRate =
|
||||
nLettersTyped > 0
|
||||
? Math.max(0, Math.min(100, ((nLettersTyped - nCharsSent) / nLettersTyped) * 100))
|
||||
: 0;
|
||||
const avgLength = nMessages > 0 ? nCharsSent / nMessages : 0;
|
||||
|
||||
// Fake revenue: ad impressions × CPM + credits spent (centi-credits → "€").
|
||||
const moneyExtorted =
|
||||
(Number(impressions ?? 0) / 1000) * FAKE_CPM + Number(creditsSpent ?? 0) / 100;
|
||||
|
||||
return {
|
||||
connectedTabs: live.connectedTabs,
|
||||
typingNow: live.typingNow,
|
||||
lettersPerSec: getLettersPerSec(),
|
||||
msgsPerMin: getMsgsPerMin(),
|
||||
messages: nMessages,
|
||||
replies: Number(replies ?? 0),
|
||||
charsSent: nCharsSent,
|
||||
lettersTyped: nLettersTyped,
|
||||
uniqueIps: Number(uniqueIps ?? 0),
|
||||
longestMsg: Number(longest ?? 0),
|
||||
abandonRate,
|
||||
avgLength,
|
||||
moneyExtorted,
|
||||
};
|
||||
}
|
||||
48
backend/src/lib/storage.ts
Normal file
48
backend/src/lib/storage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { resolve, extname } from "node:path";
|
||||
|
||||
/**
|
||||
* Filesystem storage for uploads, under backend/uploads/.
|
||||
* Files are stored under a UUID-prefixed name so a malicious client filename
|
||||
* can never traverse paths or overwrite another file. The raw bytes are never
|
||||
* executed server-side — we only ever read them back to serve downloads.
|
||||
*/
|
||||
|
||||
const UPLOADS_DIR = resolve(import.meta.dir, "../../uploads");
|
||||
|
||||
let ensured = false;
|
||||
async function ensureDir(): Promise<void> {
|
||||
if (ensured) return;
|
||||
await mkdir(UPLOADS_DIR, { recursive: true });
|
||||
ensured = true;
|
||||
}
|
||||
|
||||
/** Keep only a safe, short suffix of the original name for readability. */
|
||||
function safeSuffix(filename: string): string {
|
||||
const ext = extname(filename).slice(0, 12).replace(/[^a-zA-Z0-9.]/g, "");
|
||||
return ext || "";
|
||||
}
|
||||
|
||||
export interface StoredFile {
|
||||
storagePath: string; // relative name under uploads/
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
/** Persist a File/Blob, returning its storage path. id should be a fresh uuid. */
|
||||
export async function storeFile(id: string, file: File): Promise<StoredFile> {
|
||||
await ensureDir();
|
||||
const name = `${id}${safeSuffix(file.name)}`;
|
||||
const absolutePath = resolve(UPLOADS_DIR, name);
|
||||
// Extra guard: the resolved path must stay inside UPLOADS_DIR.
|
||||
if (!absolutePath.startsWith(UPLOADS_DIR)) {
|
||||
throw new Error("Invalid storage path");
|
||||
}
|
||||
await Bun.write(absolutePath, file);
|
||||
return { storagePath: name, absolutePath };
|
||||
}
|
||||
|
||||
export function absolutePathFor(storagePath: string): string {
|
||||
const abs = resolve(UPLOADS_DIR, storagePath);
|
||||
if (!abs.startsWith(UPLOADS_DIR)) throw new Error("Invalid storage path");
|
||||
return abs;
|
||||
}
|
||||
127
backend/src/lib/wallet.ts
Normal file
127
backend/src/lib/wallet.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { redis } from "./redis";
|
||||
import { isLocalhost } from "./ip";
|
||||
|
||||
/**
|
||||
* Wallet engine — fictional "crédits XIP", keyed on IP (no accounts).
|
||||
*
|
||||
* Amounts are integer CENTI-CREDITS to avoid float drift (display divides by 100).
|
||||
* So 9.99 "crédits" is stored as 999.
|
||||
*
|
||||
* `spend()` is the single choke point for every paid action: it enforces the
|
||||
* balance and is the one place the localhost "free mode" bypass lives.
|
||||
*/
|
||||
|
||||
// Starting grant on first wallet touch, and the free top-up button amount.
|
||||
export const SIGNUP_GRANT = 0;
|
||||
export const TOPUP_AMOUNT = 5000; // 50.00 crédits per free top-up
|
||||
|
||||
// Sentinel reported as the balance for localhost (rendered as "∞" by the UI).
|
||||
export const INFINITE = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
// Redis keys (mirror + global money counter).
|
||||
const walletKey = (ip: string) => `xip:wallet:${ip}`;
|
||||
const CREDITS_SPENT = "xip:money:credits_spent";
|
||||
|
||||
export interface WalletView {
|
||||
ip: string;
|
||||
balance: number; // centi-credits (or INFINITE for free mode)
|
||||
freeMode: boolean;
|
||||
}
|
||||
|
||||
/** Lazily create the wallet row (with the signup grant) the first time we touch an IP. */
|
||||
export async function ensureWallet(ip: string): Promise<void> {
|
||||
await prisma.wallet
|
||||
.upsert({
|
||||
where: { ip },
|
||||
create: { ip, balance: SIGNUP_GRANT },
|
||||
update: {},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
export async function getWallet(ip: string): Promise<WalletView> {
|
||||
if (isLocalhost(ip)) return { ip, balance: INFINITE, freeMode: true };
|
||||
await ensureWallet(ip);
|
||||
const w = await prisma.wallet.findUnique({ where: { ip } }).catch(() => null);
|
||||
const balance = w?.balance ?? 0;
|
||||
void redis.set(walletKey(ip), String(balance)).catch(() => {});
|
||||
return { ip, balance, freeMode: false };
|
||||
}
|
||||
|
||||
/** Free, instant, satirical top-up. No-op for localhost (already infinite). */
|
||||
export async function topUp(ip: string, amount = TOPUP_AMOUNT): Promise<WalletView> {
|
||||
if (isLocalhost(ip)) return { ip, balance: INFINITE, freeMode: true };
|
||||
await ensureWallet(ip);
|
||||
const w = await prisma.wallet.update({
|
||||
where: { ip },
|
||||
data: { balance: { increment: amount } },
|
||||
});
|
||||
await prisma.purchase
|
||||
.create({ data: { ip, type: "topup", amount } })
|
||||
.catch(() => {});
|
||||
void redis.set(walletKey(ip), String(w.balance)).catch(() => {});
|
||||
return { ip, balance: w.balance, freeMode: false };
|
||||
}
|
||||
|
||||
export class InsufficientCreditsError extends Error {
|
||||
constructor() {
|
||||
super("Crédits insuffisants");
|
||||
this.name = "InsufficientCreditsError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically spend credits. Returns the new balance.
|
||||
* - localhost => free mode: records nothing, returns INFINITE.
|
||||
* - otherwise: transactional re-read + guard + decrement + ledger row.
|
||||
* Throws InsufficientCreditsError if the balance can't cover `amount`.
|
||||
*/
|
||||
export async function spend(
|
||||
ip: string,
|
||||
amount: number,
|
||||
reason: string,
|
||||
meta?: Record<string, unknown>
|
||||
): Promise<number> {
|
||||
if (isLocalhost(ip)) return INFINITE;
|
||||
if (amount <= 0) {
|
||||
// Free item — still record the (zero) purchase for history, no balance change.
|
||||
const w = await getWallet(ip);
|
||||
await prisma.purchase
|
||||
.create({
|
||||
data: { ip, type: "purchase", amount: 0, productId: reason, metaJson: meta ? JSON.stringify(meta) : null },
|
||||
})
|
||||
.catch(() => {});
|
||||
return w.balance;
|
||||
}
|
||||
|
||||
const newBalance = await prisma.$transaction(async (tx) => {
|
||||
await tx.wallet.upsert({
|
||||
where: { ip },
|
||||
create: { ip, balance: SIGNUP_GRANT },
|
||||
update: {},
|
||||
});
|
||||
const w = await tx.wallet.findUnique({ where: { ip } });
|
||||
const current = w?.balance ?? 0;
|
||||
if (current < amount) throw new InsufficientCreditsError();
|
||||
const updated = await tx.wallet.update({
|
||||
where: { ip },
|
||||
data: { balance: { decrement: amount } },
|
||||
});
|
||||
await tx.purchase.create({
|
||||
data: {
|
||||
ip,
|
||||
type: "purchase",
|
||||
amount: -amount,
|
||||
productId: reason,
|
||||
metaJson: meta ? JSON.stringify(meta) : null,
|
||||
},
|
||||
});
|
||||
return updated.balance;
|
||||
});
|
||||
|
||||
// Mirror to Redis + bump the global "credits spent" money counter.
|
||||
void redis.set(walletKey(ip), String(newBalance)).catch(() => {});
|
||||
void redis.incrby(CREDITS_SPENT, amount).catch(() => {});
|
||||
return newBalance;
|
||||
}
|
||||
136
backend/src/realtime.ts
Normal file
136
backend/src/realtime.ts
Normal 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
40
backend/src/routes/ads.ts
Normal 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;
|
||||
67
backend/src/routes/alert.ts
Normal file
67
backend/src/routes/alert.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
14
backend/src/routes/perks.ts
Normal file
14
backend/src/routes/perks.ts
Normal 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;
|
||||
84
backend/src/routes/shop.ts
Normal file
84
backend/src/routes/shop.ts
Normal 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;
|
||||
93
backend/src/routes/uploads.ts
Normal file
93
backend/src/routes/uploads.ts
Normal 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;
|
||||
22
backend/src/routes/wallet.ts
Normal file
22
backend/src/routes/wallet.ts
Normal 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;
|
||||
Reference in New Issue
Block a user