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>
This commit is contained in:
399
server/index.js
Normal file
399
server/index.js
Normal file
@@ -0,0 +1,399 @@
|
||||
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)'}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user