Files
playbull/server/index.js
Mike 94b6cc3be9 Initial PlayBull release with Trilogy Hub integration.
Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:31:36 +02:00

400 lines
16 KiB
JavaScript

import express from 'express';
import http from 'node:http';
import path from 'node:path';
import fs from 'node:fs';
import cookieParser from 'cookie-parser';
import multer from 'multer';
import sharp from 'sharp';
import { Server as SocketServer } from 'socket.io';
import { config } from './config.js';
import { Users, Trades, Games, Feed, Uploads } from './db.js';
import {
registerUser, loginUser, signToken, setAuthCookie, clearAuthCookie,
optionalAuth, requireAuth,
} from './auth.js';
import { initMarket, listAssets, getHistory, manipulate, getAsset } from './market.js';
import { executeTrade, getPortfolio } from './trading.js';
import * as G from './games.js';
import { setIO, pushFeed } from './realtime.js';
const app = express();
const server = http.createServer(app);
const io = new SocketServer(server, { cors: { origin: true } });
setIO(io);
app.use(express.json({ limit: '30mb' }));
app.use(cookieParser());
app.use(optionalAuth);
// ---------- Trilogy Hub: Foto-Slot Persistenz (Bilder als Dateien) ----------
const SITE_UPLOAD_DIR = path.join(config.PUBLIC_DIR, 'uploads');
const SITE_MANIFEST = path.join(SITE_UPLOAD_DIR, 'manifest.json');
const SITE_EXT_BY_MIME = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp' };
fs.mkdirSync(SITE_UPLOAD_DIR, { recursive: true });
if (!fs.existsSync(SITE_MANIFEST)) fs.writeFileSync(SITE_MANIFEST, '{}');
function readSiteManifest() { try { return JSON.parse(fs.readFileSync(SITE_MANIFEST, 'utf8') || '{}'); } catch { return {}; } }
function writeSiteManifest(o) { fs.writeFileSync(SITE_MANIFEST, JSON.stringify(o, null, 2)); }
function sanitizeSlot(id) { return String(id).replace(/[^a-z0-9_-]+/gi, '_').slice(0, 120) || 'slot'; }
app.get('/api/images', (req, res) => res.json(readSiteManifest()));
app.post('/api/upload', (req, res) => {
try {
const { id, dataUrl, focusX, focusY, zoom } = req.body || {};
if (!id || !dataUrl) return res.status(400).json({ error: 'Missing id or dataUrl' });
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl);
if (!m) return res.status(400).json({ error: 'Invalid dataUrl' });
const ext = SITE_EXT_BY_MIME[m[1].toLowerCase()] || '.png';
const buf = Buffer.from(m[2], 'base64');
const filename = sanitizeSlot(id) + ext;
const manifest = readSiteManifest();
if (manifest[id]) {
const oldUrl = typeof manifest[id] === 'string' ? manifest[id] : manifest[id].url;
if (oldUrl) {
const old = path.join(config.PUBLIC_DIR, oldUrl.replace(/^\//, ''));
if (old.startsWith(SITE_UPLOAD_DIR) && fs.existsSync(old)) { try { fs.unlinkSync(old); } catch {} }
}
}
fs.writeFileSync(path.join(SITE_UPLOAD_DIR, filename), buf);
const publicUrl = '/uploads/' + filename;
const fx = Number(focusX);
const fy = Number(focusY);
const z = Number(zoom);
manifest[id] = {
url: publicUrl,
focus: {
x: Number.isFinite(fx) ? Math.min(1, Math.max(0, fx)) : 0.5,
y: Number.isFinite(fy) ? Math.min(1, Math.max(0, fy)) : 0.5,
},
zoom: Number.isFinite(z) ? Math.min(4, Math.max(1, z)) : 1,
};
writeSiteManifest(manifest);
res.json({ ok: true, url: publicUrl, focus: manifest[id].focus, zoom: manifest[id].zoom });
} catch (e) { res.status(500).json({ error: String(e?.message || e) }); }
});
// ---------- Helper ----------
function formatAvatar(u) {
if (u.avatar_image) {
return {
type: 'image',
url: u.avatar_image,
focus: { x: u.avatar_focus_x ?? 0.5, y: u.avatar_focus_y ?? 0.5 },
zoom: u.avatar_zoom ?? 1,
emoji: u.avatar || '🐂',
};
}
return { type: 'emoji', value: u.avatar || '🐂' };
}
function publicUser(u) {
if (!u) return null;
return { id: u.id, username: u.username, balance: round(u.balance), avatar: formatAvatar(u) };
}
function parseFocus(val, fallback = 0.5) {
const n = Number(val);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : fallback;
}
function parseZoom(val) {
const n = Number(val);
return Number.isFinite(n) ? Math.min(4, Math.max(1, n)) : 1;
}
function avatarFilePath(userId) {
return path.join(config.UPLOADS_DIR, `${userId}.webp`);
}
function avatarPublicPath(userId) {
return `/uploads/avatars/${userId}.webp`;
}
async function saveAvatarImage(userId, buffer) {
const out = avatarFilePath(userId);
await sharp(buffer)
.rotate()
.resize(512, 512, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(out);
return avatarPublicPath(userId);
}
function deleteAvatarFile(userId) {
const p = avatarFilePath(userId);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
const VALID_FRAMES = new Set(['gallery', 'banner', 'card', 'avatar']);
function formatUpload(row) {
if (!row) return null;
return {
id: row.id,
url: row.url,
frame: row.frame,
focus: { x: row.focus_x ?? 0.5, y: row.focus_y ?? 0.5 },
zoom: row.zoom ?? 1,
createdAt: row.created_at,
};
}
function uploadFilePath(id) {
return path.join(config.FILES_DIR, `${id}.webp`);
}
function uploadPublicPath(id) {
return `/uploads/files/${id}.webp`;
}
async function saveUploadImage(id, buffer, frame) {
const maxW = frame === 'banner' ? 1200 : frame === 'card' ? 600 : 800;
const maxH = frame === 'banner' ? 400 : frame === 'card' ? 900 : 800;
await sharp(buffer)
.rotate()
.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 85 })
.toFile(uploadFilePath(id));
return uploadPublicPath(id);
}
function deleteUploadFile(id) {
const p = uploadFilePath(id);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
function parseFrame(val) {
const f = String(val || 'gallery');
return VALID_FRAMES.has(f) ? f : 'gallery';
}
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ok = /^image\/(jpeg|png|webp|gif)$/.test(file.mimetype);
cb(ok ? null : new Error('Nur JPEG, PNG, WebP oder GIF'), ok);
},
});
function round(n, d = 2) { const f = Math.pow(10, d); return Math.round(n * f) / f; }
const ok = (res, data) => res.json({ ok: true, ...data });
const fail = (res, msg, code = 400) => res.status(code).json({ ok: false, error: msg });
// ========== AUTH ==========
app.post('/api/register', async (req, res) => {
try {
const u = await registerUser(req.body.username, req.body.password);
const token = signToken(u);
setAuthCookie(res, token);
pushFeed('join', `🎉 ${u.username} ist PlayBull beigetreten!`);
ok(res, { user: publicUser(u) });
} catch (e) { fail(res, e.message); }
});
app.post('/api/login', async (req, res) => {
try {
const u = await loginUser(req.body.username, req.body.password);
const token = signToken(u);
setAuthCookie(res, token);
ok(res, { user: publicUser(u) });
} catch (e) { fail(res, e.message, 401); }
});
app.post('/api/logout', (req, res) => { clearAuthCookie(res); ok(res, {}); });
app.get('/api/me', (req, res) => {
ok(res, { user: publicUser(req.user) });
});
// ========== MARKT (oeffentlich) ==========
app.get('/api/market', (req, res) => {
ok(res, { assets: listAssets() });
});
app.get('/api/market/:symbol/history', (req, res) => {
const sym = req.params.symbol.toUpperCase();
if (!getAsset(sym)) return fail(res, 'Unbekanntes Symbol', 404);
ok(res, { symbol: sym, history: getHistory(sym) });
});
// ========== TRADING (Login noetig) ==========
app.get('/api/portfolio', requireAuth, (req, res) => {
ok(res, { portfolio: getPortfolio(req.user.id) });
});
app.post('/api/trade', requireAuth, (req, res) => {
const { symbol, side, qty } = req.body;
if (!['buy', 'sell'].includes(side)) return fail(res, 'side muss buy/sell sein');
const r = executeTrade(req.user.id, String(symbol).toUpperCase(), side, qty);
if (!r.ok) return fail(res, r.error);
const a = getAsset(String(symbol).toUpperCase());
pushFeed('trade', `${req.user.username} ${side === 'buy' ? 'kauft' : 'verkauft'} ${round(qty,4)} ${a?.emoji||''}${symbol.toUpperCase()} @ ${r.price}`);
io.emit('portfolio_dirty', { userId: req.user.id });
ok(res, r);
});
app.get('/api/trades', requireAuth, (req, res) => {
ok(res, { trades: Trades.forUser(req.user.id) });
});
// ========== MANIPULATION (jeder eingeloggte User - Spassmodus) ==========
app.post('/api/manipulate', requireAuth, (req, res) => {
const { symbol, action, value } = req.body;
const r = manipulate(String(symbol).toUpperCase(), action, Number(value), req.user.username);
if (!r.ok) return fail(res, r.error);
const verb = { pump: '🚀 pumpt', dump: '📉 dumpt', set: '✏️ setzt', halt: '⏸️ pausiert', resume: '▶️ aktiviert', reset: '♻️ resettet' }[action] || action;
pushFeed('manip', `${req.user.username} ${verb} ${symbol.toUpperCase()}`);
ok(res, r);
});
// ========== SETTINGS / ADMIN (Spassmodus: jeder darf alles) ==========
app.get('/api/users', requireAuth, (req, res) => {
ok(res, { users: Users.all().map(u => ({ ...u, avatar: formatAvatar(u) })) });
});
// Geld bearbeiten - bei sich selbst oder anderen
app.post('/api/admin/balance', requireAuth, (req, res) => {
const { userId, mode, amount } = req.body; // mode: 'set' | 'add'
const target = Users.byId(Number(userId));
if (!target) return fail(res, 'User nicht gefunden', 404);
const amt = Number(amount);
if (!Number.isFinite(amt)) return fail(res, 'Betrag ungueltig');
let nb;
if (mode === 'set') { Users.setBalance(target.id, amt); nb = amt; }
else { nb = Users.addBalance(target.id, amt); }
pushFeed('admin', `${req.user.username} ${mode === 'set' ? 'setzt' : 'aendert'} Guthaben von ${target.username}${round(nb)} 🪙`);
io.emit('balance_changed', { userId: target.id, balance: round(nb) });
ok(res, { userId: target.id, balance: round(nb) });
});
app.post('/api/settings/avatar', requireAuth, (req, res) => {
const avatar = String(req.body.avatar || '🐂').slice(0, 4);
Users.setAvatar(req.user.id, avatar);
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
});
app.post('/api/settings/avatar/image', requireAuth, (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) return fail(res, err.message || 'Upload fehlgeschlagen');
next();
});
}, async (req, res) => {
try {
if (!req.file) return fail(res, 'Keine Bilddatei');
const focusX = parseFocus(req.body.focusX);
const focusY = parseFocus(req.body.focusY);
const zoom = parseZoom(req.body.zoom);
deleteAvatarFile(req.user.id);
const url = await saveAvatarImage(req.user.id, req.file.buffer);
Users.setAvatarImage(req.user.id, url, focusX, focusY, zoom);
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
} catch (e) {
fail(res, e.message || 'Upload fehlgeschlagen');
}
});
app.patch('/api/settings/avatar/image/position', requireAuth, (req, res) => {
const u = Users.byId(req.user.id);
if (!u?.avatar_image) return fail(res, 'Kein Profilbild vorhanden', 404);
const focusX = parseFocus(req.body.focusX);
const focusY = parseFocus(req.body.focusY);
const zoom = parseZoom(req.body.zoom);
Users.setAvatarPosition(req.user.id, focusX, focusY, zoom);
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
});
app.delete('/api/settings/avatar/image', requireAuth, (req, res) => {
deleteAvatarFile(req.user.id);
Users.clearAvatarImage(req.user.id);
ok(res, { avatar: formatAvatar(Users.byId(req.user.id)) });
});
// ========== UPLOADS (Galerie) ==========
app.get('/api/uploads', requireAuth, (req, res) => {
ok(res, { uploads: Uploads.forUser(req.user.id).map(formatUpload) });
});
app.post('/api/uploads', requireAuth, (req, res, next) => {
upload.single('file')(req, res, (err) => {
if (err) return fail(res, err.message || 'Upload fehlgeschlagen');
next();
});
}, async (req, res) => {
let row = null;
try {
if (!req.file) return fail(res, 'Keine Bilddatei');
const frame = parseFrame(req.body.frame);
const focusX = parseFocus(req.body.focusX);
const focusY = parseFocus(req.body.focusY);
const zoom = parseZoom(req.body.zoom);
row = Uploads.create(req.user.id, frame, focusX, focusY, zoom);
const url = await saveUploadImage(row.id, req.file.buffer, frame);
Uploads.setUrl(row.id, url);
ok(res, { upload: formatUpload(Uploads.byId(row.id)) });
} catch (e) {
if (row) { deleteUploadFile(row.id); Uploads.remove(row.id); }
fail(res, e.message || 'Upload fehlgeschlagen');
}
});
app.patch('/api/uploads/:id/position', requireAuth, (req, res) => {
const row = Uploads.byId(+req.params.id);
if (!row || row.user_id !== req.user.id) return fail(res, 'Upload nicht gefunden', 404);
const focusX = parseFocus(req.body.focusX);
const focusY = parseFocus(req.body.focusY);
const zoom = parseZoom(req.body.zoom);
Uploads.setPosition(row.id, focusX, focusY, zoom);
ok(res, { upload: formatUpload(Uploads.byId(row.id)) });
});
app.delete('/api/uploads/:id', requireAuth, (req, res) => {
const row = Uploads.byId(+req.params.id);
if (!row || row.user_id !== req.user.id) return fail(res, 'Upload nicht gefunden', 404);
deleteUploadFile(row.id);
Uploads.remove(row.id);
ok(res, {});
});
// ========== FEED / LEADERBOARD ==========
app.get('/api/feed', (req, res) => { ok(res, { feed: Feed.recent(30) }); });
app.get('/api/leaderboard', (req, res) => {
const users = Users.all().slice(0, 20).map(u => {
const p = getPortfolio(u.id);
return { username: u.username, avatar: formatAvatar(u), netWorth: p ? p.netWorth : u.balance };
}).sort((a, b) => b.netWorth - a.netWorth);
ok(res, { leaderboard: users });
});
// ========== GAMES (Login noetig) ==========
const gameRoute = (fn) => (req, res) => {
const r = fn(req.user.id, req.body);
if (!r || !r.ok) return fail(res, r?.error || 'Fehler');
ok(res, r);
};
app.post('/api/games/chicken/start', requireAuth, gameRoute((id, b) => G.chickenStart(id, b.bet, b.difficulty)));
app.post('/api/games/chicken/step', requireAuth, gameRoute((id, b) => G.chickenStep(id, b.roundId)));
app.post('/api/games/chicken/cashout', requireAuth, gameRoute((id, b) => G.chickenCashout(id, b.roundId)));
app.post('/api/games/mines/start', requireAuth, gameRoute((id, b) => G.minesStart(id, b.bet, b.bombs)));
app.post('/api/games/mines/reveal', requireAuth, gameRoute((id, b) => G.minesReveal(id, b.roundId, b.index)));
app.post('/api/games/mines/cashout', requireAuth, gameRoute((id, b) => G.minesCashout(id, b.roundId)));
app.post('/api/games/coinflip', requireAuth, gameRoute((id, b) => G.coinflip(id, b.bet, b.choice)));
app.post('/api/games/dice', requireAuth, gameRoute((id, b) => G.dice(id, b.bet, b.target, b.direction)));
app.post('/api/games/crash/start', requireAuth, gameRoute((id, b) => G.crashStart(id, b.bet)));
app.post('/api/games/crash/status', requireAuth, gameRoute((id, b) => G.crashStatus(id, b.roundId)));
app.post('/api/games/crash/cashout', requireAuth, gameRoute((id, b) => G.crashCashout(id, b.roundId)));
app.get('/api/games/history', requireAuth, (req, res) => {
ok(res, { history: Games.forUser(req.user.id) });
});
// ---------- Static Frontend ----------
app.use('/uploads/avatars', express.static(config.UPLOADS_DIR));
app.use('/uploads/files', express.static(config.FILES_DIR));
app.use(express.static(config.PUBLIC_DIR));
app.get('*', (req, res) => res.sendFile(path.join(config.PUBLIC_DIR, 'index.html')));
// ---------- Socket.IO ----------
io.on('connection', (socket) => {
socket.emit('hello', { assets: listAssets(), feed: Feed.recent(20) });
});
// ---------- Start ----------
initMarket(io).then(() => {
server.listen(config.PORT, () => {
console.log(`\n 🐂 PlayBull laeuft auf http://localhost:${config.PORT}\n`);
console.log(` Finnhub-Key: ${config.FINNHUB_API_KEY ? 'aktiv (echte Aktienkurse)' : 'fehlt (nur Simulation)'}`);
});
});