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)'}`); }); });