From b673f6fcb35a29b34c374fc7997a7e20f29ab221 Mon Sep 17 00:00:00 2001 From: b3ni15 Date: Sat, 20 Dec 2025 23:02:19 +0100 Subject: [PATCH] Implement backend for blackjack game with Discord authentication, database integration, and WebSocket support --- .env.example | 23 +++ package.json | 25 +++ schema.sql | 22 +++ src/auth.js | 33 ++++ src/db.js | 19 ++ src/discord.js | 51 +++++ src/game/blackjack.js | 54 +++++ src/game/tables.js | 446 ++++++++++++++++++++++++++++++++++++++++++ src/index.js | 39 ++++ src/routes/auth.js | 80 ++++++++ src/routes/lobby.js | 20 ++ src/routes/stripe.js | 49 +++++ src/routes/wallet.js | 38 ++++ src/ws.js | 117 +++++++++++ 14 files changed, 1016 insertions(+) create mode 100644 .env.example create mode 100644 package.json create mode 100644 schema.sql create mode 100644 src/auth.js create mode 100644 src/db.js create mode 100644 src/discord.js create mode 100644 src/game/blackjack.js create mode 100644 src/game/tables.js create mode 100644 src/index.js create mode 100644 src/routes/auth.js create mode 100644 src/routes/lobby.js create mode 100644 src/routes/stripe.js create mode 100644 src/routes/wallet.js create mode 100644 src/ws.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3118f3f --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +PORT=4000 +CORS_ORIGIN=* +JWT_SECRET=valtoztasd_meg +JWT_EXPIRES_IN=7d + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=blackjack +DB_PASSWORD=blackjack +DB_NAME=blackjack + +DISCORD_CLIENT_ID=ide +DISCORD_CLIENT_SECRET=ide +DISCORD_REDIRECT_URI=http://localhost:4000/auth/discord/callback +DEFAULT_APP_REDIRECT=blackjack://auth + +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +MIN_BET=10 +MAX_BET=100 +ROUND_START_DELAY_MS=3000 +ROUND_RESET_DELAY_MS=5000 diff --git a/package.json b/package.json new file mode 100644 index 0000000..270b8ac --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "blackjack-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.js", + "scripts": { + "dev": "nodemon src/index.js", + "start": "node src/index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "mysql2": "^3.10.0", + "stripe": "^16.2.0", + "ws": "^8.17.0" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..0c40085 --- /dev/null +++ b/schema.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + discord_id VARCHAR(32) NOT NULL UNIQUE, + username VARCHAR(100) NOT NULL, + avatar VARCHAR(128) NOT NULL DEFAULT '', + balance INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS deposits ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + amount INT NOT NULL, + stripe_payment_intent_id VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'created', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_deposits_user_id FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE INDEX idx_deposits_stripe ON deposits (stripe_payment_intent_id); diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..e93cc06 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,33 @@ +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_change_me'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; + +export function signToken(user) { + return jwt.sign( + { sub: user.id, username: user.username }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); +} + +export function verifyToken(token) { + return jwt.verify(token, JWT_SECRET); +} + +export function authMiddleware(req, res, next) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) { + return res.status(401).json({ error: 'Nincs hitelesites.' }); + } + + try { + const payload = jwt.verify(token, JWT_SECRET); + req.userId = Number(payload.sub); + req.username = payload.username; + return next(); + } catch (err) { + return res.status(401).json({ error: 'Ervenytelen token.' }); + } +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..963559c --- /dev/null +++ b/src/db.js @@ -0,0 +1,19 @@ +import mysql from 'mysql2/promise'; + +const pool = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: Number(process.env.DB_PORT || 3306), + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}); + +export async function query(sql, params = []) { + const [rows] = await pool.execute(sql, params); + return rows; +} + +export default pool; diff --git a/src/discord.js b/src/discord.js new file mode 100644 index 0000000..b0c28e6 --- /dev/null +++ b/src/discord.js @@ -0,0 +1,51 @@ +import { URLSearchParams } from 'url'; + +const DISCORD_API = 'https://discord.com/api'; + +export function getDiscordAuthUrl(state) { + const params = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID, + redirect_uri: process.env.DISCORD_REDIRECT_URI, + response_type: 'code', + scope: 'identify', + state + }); + + return `${DISCORD_API}/oauth2/authorize?${params.toString()}`; +} + +export async function exchangeCodeForToken(code) { + const body = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID, + client_secret: process.env.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: process.env.DISCORD_REDIRECT_URI + }); + + const response = await fetch(`${DISCORD_API}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Discord token hiba: ${text}`); + } + + return response.json(); +} + +export async function fetchDiscordUser(accessToken) { + const response = await fetch(`${DISCORD_API}/users/@me`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Discord user hiba: ${text}`); + } + + return response.json(); +} diff --git a/src/game/blackjack.js b/src/game/blackjack.js new file mode 100644 index 0000000..8a37174 --- /dev/null +++ b/src/game/blackjack.js @@ -0,0 +1,54 @@ +const SUITS = ['S', 'H', 'D', 'C']; +const RANKS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']; + +export function createDeck() { + const deck = []; + for (const suit of SUITS) { + for (const rank of RANKS) { + deck.push({ rank, suit }); + } + } + return deck; +} + +export function shuffle(deck) { + for (let i = deck.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [deck[i], deck[j]] = [deck[j], deck[i]]; + } + return deck; +} + +export function draw(deck) { + return deck.pop(); +} + +export function handValue(hand) { + let total = 0; + let aces = 0; + + for (const card of hand) { + if (card.rank === 'A') { + total += 11; + aces += 1; + } else if (['J', 'Q', 'K'].includes(card.rank)) { + total += 10; + } else { + total += Number(card.rank); + } + } + + while (total > 21 && aces > 0) { + total -= 10; + aces -= 1; + } + + return { + total, + soft: aces > 0 + }; +} + +export function isBlackjack(hand) { + return hand.length === 2 && handValue(hand).total === 21; +} diff --git a/src/game/tables.js b/src/game/tables.js new file mode 100644 index 0000000..14848b2 --- /dev/null +++ b/src/game/tables.js @@ -0,0 +1,446 @@ +import { createDeck, shuffle, draw, handValue, isBlackjack } from './blackjack.js'; +import { query } from '../db.js'; + +const MIN_BET = Number(process.env.MIN_BET || 10); +const MAX_BET = Number(process.env.MAX_BET || 100); +const ROUND_START_DELAY_MS = Number(process.env.ROUND_START_DELAY_MS || 3000); +const ROUND_RESET_DELAY_MS = Number(process.env.ROUND_RESET_DELAY_MS || 5000); + +class Table { + constructor(id, seatCount) { + this.id = id; + this.seatCount = seatCount; + this.seats = Array.from({ length: seatCount }, () => ({ + userId: null, + username: null, + bet: 0, + hand: [], + status: 'empty', + ready: false, + result: null + })); + this.phase = 'waiting'; + this.dealerHand = []; + this.currentSeatIndex = null; + this.deck = []; + this.roundId = 0; + this.roundTimeout = null; + this.resetTimeout = null; + this.clients = new Set(); + } + + snapshot() { + const occupied = this.seats.filter((seat) => seat.userId).length; + return { + id: this.id, + seatCount: this.seatCount, + occupied, + phase: this.phase, + minBet: MIN_BET, + maxBet: MAX_BET + }; + } + + stateFor(userId) { + const dealerHand = this.dealerHand.map((card, index) => { + if (this.phase === 'playing' && index === 1) { + return { rank: 'X', suit: 'X', hidden: true }; + } + return { ...card, hidden: false }; + }); + + return { + id: this.id, + phase: this.phase, + minBet: MIN_BET, + maxBet: MAX_BET, + roundId: this.roundId, + dealerHand, + currentSeatIndex: this.currentSeatIndex, + seats: this.seats.map((seat, index) => ({ + index, + username: seat.username, + bet: seat.bet, + hand: seat.hand, + status: seat.status, + ready: seat.ready, + result: seat.result, + isYou: seat.userId === userId + })) + }; + } + + addClient(ws) { + this.clients.add(ws); + } + + removeClient(ws) { + this.clients.delete(ws); + } + + broadcast(payload, forUserId = null) { + const message = JSON.stringify(payload); + for (const client of this.clients) { + if (client.readyState !== 1) { + continue; + } + if (forUserId && client.user && client.user.id !== forUserId) { + continue; + } + client.send(message); + } + } + + broadcastState() { + for (const client of this.clients) { + if (client.readyState !== 1 || !client.user) { + continue; + } + client.send(JSON.stringify({ + type: 'table_state', + table: this.stateFor(client.user.id) + })); + } + } + + findSeatIndexByUser(userId) { + return this.seats.findIndex((seat) => seat.userId === userId); + } + + async join(user) { + const existing = this.findSeatIndexByUser(user.id); + if (existing !== -1) { + return existing; + } + + const index = this.seats.findIndex((seat) => !seat.userId); + if (index === -1) { + throw new Error('Nincs szabad hely az asztalnal.'); + } + + this.seats[index] = { + userId: user.id, + username: user.username, + bet: 0, + hand: [], + status: 'waiting', + ready: false, + result: null + }; + + if (this.phase === 'waiting') { + this.phase = 'betting'; + } + + this.broadcastState(); + return index; + } + + async leave(userId, reason = 'left') { + const index = this.findSeatIndexByUser(userId); + if (index === -1) { + return; + } + + const seat = this.seats[index]; + if (seat.bet > 0 && this.phase === 'playing') { + seat.result = { outcome: reason, payout: 0 }; + } + + this.seats[index] = { + userId: null, + username: null, + bet: 0, + hand: [], + status: 'empty', + ready: false, + result: null + }; + + if (this.currentSeatIndex === index) { + this.advanceTurn(); + } + + if (this.seats.every((seat) => !seat.userId)) { + this.phase = 'waiting'; + } + + this.broadcastState(); + } + + async placeBet(userId, amount) { + if (this.phase === 'playing') { + throw new Error('A kor mar fut, varj a kovetkezo korig.'); + } + + if (!Number.isFinite(amount) || amount < MIN_BET || amount > MAX_BET) { + throw new Error(`A tet ${MIN_BET}-${MAX_BET} Ft kozott lehet.`); + } + + const index = this.findSeatIndexByUser(userId); + if (index === -1) { + throw new Error('Nem ulsz az asztalnal.'); + } + + const balanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]); + const balance = balanceRows[0]?.balance ?? 0; + if (balance < amount) { + throw new Error('Nincs eleg egyenleged.'); + } + + await query('UPDATE users SET balance = balance - ? WHERE id = ?', [amount, userId]); + const updatedBalanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]); + const updatedBalance = updatedBalanceRows[0]?.balance ?? 0; + + const seat = this.seats[index]; + seat.bet = amount; + seat.ready = false; + seat.status = 'bet'; + seat.result = null; + + if (this.phase === 'waiting') { + this.phase = 'betting'; + } + + this.broadcastState(); + this.broadcast({ type: 'balance', balance: updatedBalance }, userId); + return amount; + } + + async readyUp(userId) { + const index = this.findSeatIndexByUser(userId); + if (index === -1) { + throw new Error('Nem ulsz az asztalnal.'); + } + + const seat = this.seats[index]; + if (seat.bet <= 0) { + throw new Error('Eloszor tegyel tetet.'); + } + + seat.ready = true; + this.broadcastState(); + this.scheduleRound(); + } + + scheduleRound() { + if (this.roundTimeout || this.phase === 'playing') { + return; + } + + this.roundTimeout = setTimeout(() => { + this.roundTimeout = null; + const hasBets = this.seats.some((seat) => seat.bet > 0); + if (!hasBets) { + return; + } + void this.startRound(); + }, ROUND_START_DELAY_MS); + } + + async startRound() { + if (this.phase === 'playing') { + return; + } + + this.phase = 'playing'; + this.roundId += 1; + this.deck = shuffle(createDeck()); + this.dealerHand = [draw(this.deck), draw(this.deck)]; + + for (const seat of this.seats) { + if (seat.bet > 0) { + seat.hand = [draw(this.deck), draw(this.deck)]; + seat.status = isBlackjack(seat.hand) ? 'blackjack' : 'playing'; + seat.ready = false; + } else if (seat.userId) { + seat.status = 'waiting'; + } + } + + this.currentSeatIndex = this.nextPlayableSeatIndex(-1); + if (this.currentSeatIndex === null) { + await this.dealerTurn(); + return; + } + + this.broadcastState(); + } + + nextPlayableSeatIndex(fromIndex) { + for (let offset = 1; offset <= this.seatCount; offset += 1) { + const index = (fromIndex + offset) % this.seatCount; + const seat = this.seats[index]; + if (seat.bet > 0 && seat.status === 'playing') { + return index; + } + } + return null; + } + + async handleAction(userId, action) { + if (this.phase !== 'playing') { + throw new Error('Most nincs jatekban kor.'); + } + + const index = this.findSeatIndexByUser(userId); + if (index !== this.currentSeatIndex) { + throw new Error('Nem te jossz.'); + } + + const seat = this.seats[index]; + if (seat.status !== 'playing') { + throw new Error('A kezed mar lezart.'); + } + + if (action === 'hit') { + seat.hand.push(draw(this.deck)); + const value = handValue(seat.hand).total; + if (value > 21) { + seat.status = 'bust'; + this.advanceTurn(); + } else if (value === 21) { + seat.status = 'stood'; + this.advanceTurn(); + } + } else if (action === 'stand') { + seat.status = 'stood'; + this.advanceTurn(); + } else if (action === 'double') { + if (seat.hand.length !== 2) { + throw new Error('Duplazni csak az elso ket lap utan lehet.'); + } + + const balanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]); + const balance = balanceRows[0]?.balance ?? 0; + if (balance < seat.bet) { + throw new Error('Nincs eleg egyenleged a duplazashoz.'); + } + + await query('UPDATE users SET balance = balance - ? WHERE id = ?', [seat.bet, userId]); + const updatedBalanceRows = await query('SELECT balance FROM users WHERE id = ?', [userId]); + const updatedBalance = updatedBalanceRows[0]?.balance ?? 0; + seat.bet += seat.bet; + seat.hand.push(draw(this.deck)); + seat.status = 'stood'; + this.advanceTurn(); + this.broadcast({ type: 'balance', balance: updatedBalance }, userId); + } else { + throw new Error('Ismeretlen akcio.'); + } + + this.broadcastState(); + } + + async advanceTurn() { + const nextIndex = this.nextPlayableSeatIndex(this.currentSeatIndex ?? -1); + if (nextIndex === null) { + await this.dealerTurn(); + return; + } + + this.currentSeatIndex = nextIndex; + this.broadcastState(); + } + + async dealerTurn() { + this.phase = 'dealer'; + this.currentSeatIndex = null; + + let dealerValue = handValue(this.dealerHand); + while (dealerValue.total < 17) { + this.dealerHand.push(draw(this.deck)); + dealerValue = handValue(this.dealerHand); + } + + await this.payout(); + } + + async payout() { + const dealerValue = handValue(this.dealerHand).total; + const dealerBlackjack = isBlackjack(this.dealerHand); + const dealerBust = dealerValue > 21; + + for (const seat of this.seats) { + if (!seat.userId || seat.bet <= 0) { + continue; + } + + const playerValue = handValue(seat.hand).total; + const playerBlackjack = isBlackjack(seat.hand); + let payout = 0; + let outcome = 'lose'; + + if (seat.status === 'bust') { + outcome = 'bust'; + } else if (dealerBlackjack && playerBlackjack) { + payout = seat.bet; + outcome = 'push'; + } else if (dealerBlackjack && !playerBlackjack) { + payout = 0; + outcome = 'lose'; + } else if (playerBlackjack && !dealerBlackjack) { + payout = Math.floor(seat.bet * 2.5); + outcome = 'blackjack'; + } else if (dealerBust) { + payout = seat.bet * 2; + outcome = 'win'; + } else if (playerValue > dealerValue) { + payout = seat.bet * 2; + outcome = 'win'; + } else if (playerValue === dealerValue) { + payout = seat.bet; + outcome = 'push'; + } + + seat.result = { outcome, payout }; + if (payout > 0) { + await query('UPDATE users SET balance = balance + ? WHERE id = ?', [payout, seat.userId]); + } + const updatedBalanceRows = await query('SELECT balance FROM users WHERE id = ?', [seat.userId]); + const updatedBalance = updatedBalanceRows[0]?.balance ?? 0; + this.broadcast({ type: 'balance', balance: updatedBalance }, seat.userId); + } + + this.phase = 'payout'; + this.broadcastState(); + + if (this.resetTimeout) { + clearTimeout(this.resetTimeout); + } + + this.resetTimeout = setTimeout(() => { + this.resetRound(); + }, ROUND_RESET_DELAY_MS); + } + + resetRound() { + for (const seat of this.seats) { + if (!seat.userId) { + continue; + } + seat.bet = 0; + seat.hand = []; + seat.status = 'waiting'; + seat.ready = false; + seat.result = null; + } + + this.dealerHand = []; + this.currentSeatIndex = null; + this.phase = this.seats.some((seat) => seat.userId) ? 'betting' : 'waiting'; + this.broadcastState(); + } +} + +const tables = Array.from({ length: 3 }, (_, index) => new Table(index + 1, 7)); + +export function listTables() { + return tables.map((table) => table.snapshot()); +} + +export function getTableById(id) { + return tables.find((table) => table.id === Number(id)); +} + +export default tables; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..b29c53c --- /dev/null +++ b/src/index.js @@ -0,0 +1,39 @@ +import dotenv from 'dotenv'; +import express from 'express'; +import http from 'http'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import { WebSocketServer } from 'ws'; +import authRoutes from './routes/auth.js'; +import lobbyRoutes from './routes/lobby.js'; +import walletRoutes from './routes/wallet.js'; +import stripeRoutes from './routes/stripe.js'; +import { setupWebSocket } from './ws.js'; + +dotenv.config(); + +const app = express(); + +app.use(helmet()); +app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); +app.use(morgan('dev')); + +app.use(stripeRoutes); +app.use(express.json()); + +app.get('/health', (req, res) => res.json({ ok: true })); + +app.use(authRoutes); +app.use(lobbyRoutes); +app.use(walletRoutes); + +const server = http.createServer(app); +const wss = new WebSocketServer({ server }); +setupWebSocket(wss); + +const PORT = Number(process.env.PORT || 4000); +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Backend fut: http://localhost:${PORT}`); +}); diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..552c019 --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,80 @@ +import { Router } from 'express'; +import crypto from 'crypto'; +import { getDiscordAuthUrl, exchangeCodeForToken, fetchDiscordUser } from '../discord.js'; +import { query } from '../db.js'; +import { signToken } from '../auth.js'; + +const router = Router(); +const stateStore = new Map(); +const STATE_TTL_MS = 10 * 60 * 1000; + +function storeState(state, redirect) { + stateStore.set(state, { redirect, createdAt: Date.now() }); +} + +function consumeState(state) { + const entry = stateStore.get(state); + stateStore.delete(state); + if (!entry) { + return null; + } + if (Date.now() - entry.createdAt > STATE_TTL_MS) { + return null; + } + return entry; +} + +router.get('/auth/discord/url', (req, res) => { + const state = crypto.randomUUID(); + const redirect = req.query.redirect?.toString() || process.env.DEFAULT_APP_REDIRECT || ''; + storeState(state, redirect); + const url = getDiscordAuthUrl(state); + res.json({ url }); +}); + +router.get('/auth/discord/callback', async (req, res) => { + try { + const { code, state } = req.query; + if (!code || !state) { + return res.status(400).send('Hibas visszairanyitas.'); + } + + const entry = consumeState(state.toString()); + if (!entry) { + return res.status(400).send('Ervenytelen state.'); + } + + const tokenResponse = await exchangeCodeForToken(code.toString()); + const discordUser = await fetchDiscordUser(tokenResponse.access_token); + + const existing = await query('SELECT id FROM users WHERE discord_id = ?', [discordUser.id]); + let userId; + if (existing.length > 0) { + userId = existing[0].id; + await query('UPDATE users SET username = ?, avatar = ? WHERE id = ?', [ + discordUser.username, + discordUser.avatar || '', + userId + ]); + } else { + const result = await query( + 'INSERT INTO users (discord_id, username, avatar, balance) VALUES (?, ?, ?, ?)', + [discordUser.id, discordUser.username, discordUser.avatar || '', 0] + ); + userId = result.insertId; + } + + const token = signToken({ id: userId, username: discordUser.username }); + + if (entry.redirect) { + const separator = entry.redirect.includes('?') ? '&' : '?'; + return res.redirect(`${entry.redirect}${separator}token=${encodeURIComponent(token)}`); + } + + return res.json({ token }); + } catch (err) { + return res.status(500).send('Hiba a bejelentkezes kozben.'); + } +}); + +export default router; diff --git a/src/routes/lobby.js b/src/routes/lobby.js new file mode 100644 index 0000000..16932a8 --- /dev/null +++ b/src/routes/lobby.js @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { authMiddleware } from '../auth.js'; +import { query } from '../db.js'; +import { listTables } from '../game/tables.js'; + +const router = Router(); + +router.get('/api/me', authMiddleware, async (req, res) => { + const rows = await query('SELECT id, username, avatar, balance FROM users WHERE id = ?', [req.userId]); + if (!rows[0]) { + return res.status(404).json({ error: 'Nincs felhasznalo.' }); + } + return res.json(rows[0]); +}); + +router.get('/api/tables', authMiddleware, (req, res) => { + return res.json({ tables: listTables() }); +}); + +export default router; diff --git a/src/routes/stripe.js b/src/routes/stripe.js new file mode 100644 index 0000000..e212306 --- /dev/null +++ b/src/routes/stripe.js @@ -0,0 +1,49 @@ +import express, { Router } from 'express'; +import Stripe from 'stripe'; +import { query } from '../db.js'; + +const router = Router(); +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', { + apiVersion: '2024-06-20' +}); + +router.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => { + const signature = req.headers['stripe-signature']; + let event; + + try { + event = stripe.webhooks.constructEvent( + req.body, + signature, + process.env.STRIPE_WEBHOOK_SECRET || '' + ); + } catch (err) { + return res.status(400).send('Webhook alairas hiba.'); + } + + if (event.type === 'payment_intent.succeeded') { + const intent = event.data.object; + const amount = Number(intent.amount || 0); + const userId = Number(intent.metadata?.userId || 0); + + if (userId && amount) { + const rows = await query( + 'SELECT status FROM deposits WHERE stripe_payment_intent_id = ?', + [intent.id] + ); + const status = rows[0]?.status; + + if (status !== 'succeeded') { + await query( + 'UPDATE deposits SET status = ? WHERE stripe_payment_intent_id = ?', + ['succeeded', intent.id] + ); + await query('UPDATE users SET balance = balance + ? WHERE id = ?', [amount, userId]); + } + } + } + + return res.json({ received: true }); +}); + +export default router; diff --git a/src/routes/wallet.js b/src/routes/wallet.js new file mode 100644 index 0000000..52299f2 --- /dev/null +++ b/src/routes/wallet.js @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import Stripe from 'stripe'; +import { authMiddleware } from '../auth.js'; +import { query } from '../db.js'; + +const router = Router(); +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', { + apiVersion: '2024-06-20' +}); + +router.post('/api/wallet/deposit-intent', authMiddleware, async (req, res) => { + try { + const amount = Number(req.body.amount); + if (!Number.isFinite(amount) || amount < 50 || amount > 100) { + return res.status(400).json({ error: 'A feltoltes 50 es 100 Ft kozott lehet.' }); + } + + const paymentIntent = await stripe.paymentIntents.create({ + amount, + currency: 'huf', + automatic_payment_methods: { enabled: true }, + metadata: { + userId: String(req.userId) + } + }); + + await query( + 'INSERT INTO deposits (user_id, amount, stripe_payment_intent_id, status) VALUES (?, ?, ?, ?)', + [req.userId, amount, paymentIntent.id, 'created'] + ); + + return res.json({ clientSecret: paymentIntent.client_secret }); + } catch (err) { + return res.status(500).json({ error: 'Nem sikerult letrehozni a fizetest.' }); + } +}); + +export default router; diff --git a/src/ws.js b/src/ws.js new file mode 100644 index 0000000..7d201af --- /dev/null +++ b/src/ws.js @@ -0,0 +1,117 @@ +import { verifyToken } from './auth.js'; +import { query } from './db.js'; +import { getTableById } from './game/tables.js'; + +function safeSend(ws, payload) { + if (ws.readyState === 1) { + ws.send(JSON.stringify(payload)); + } +} + +export function setupWebSocket(wss) { + wss.on('connection', (ws) => { + ws.user = null; + ws.tableId = null; + + ws.on('message', async (data) => { + let message; + try { + message = JSON.parse(data.toString()); + } catch (err) { + safeSend(ws, { type: 'error', message: 'Hibas uzenet formatum.' }); + return; + } + + try { + if (message.type === 'hello') { + const payload = verifyToken(message.token); + const rows = await query('SELECT id, username, balance FROM users WHERE id = ?', [payload.sub]); + const user = rows[0]; + if (!user) { + throw new Error('Ismeretlen felhasznalo.'); + } + + ws.user = { id: user.id, username: user.username }; + safeSend(ws, { type: 'welcome', user: ws.user, balance: user.balance }); + return; + } + + if (!ws.user) { + throw new Error('Nincs hitelesites.'); + } + + if (message.type === 'join') { + const table = getTableById(message.tableId); + if (!table) { + throw new Error('Ismeretlen asztal.'); + } + + if (ws.tableId && ws.tableId !== table.id) { + const previous = getTableById(ws.tableId); + if (previous) { + await previous.leave(ws.user.id, 'moved'); + previous.removeClient(ws); + } + } + + ws.tableId = table.id; + table.addClient(ws); + await table.join(ws.user); + safeSend(ws, { type: 'joined', tableId: table.id }); + return; + } + + if (message.type === 'leave') { + if (ws.tableId) { + const table = getTableById(ws.tableId); + if (table) { + await table.leave(ws.user.id, 'left'); + table.removeClient(ws); + } + } + ws.tableId = null; + safeSend(ws, { type: 'left' }); + return; + } + + if (!ws.tableId) { + throw new Error('Nem vagy asztalnal.'); + } + + const table = getTableById(ws.tableId); + if (!table) { + throw new Error('Ismeretlen asztal.'); + } + + if (message.type === 'bet') { + await table.placeBet(ws.user.id, Number(message.amount)); + return; + } + + if (message.type === 'ready') { + await table.readyUp(ws.user.id); + return; + } + + if (message.type === 'action') { + await table.handleAction(ws.user.id, message.action); + return; + } + + safeSend(ws, { type: 'error', message: 'Ismeretlen uzenet.' }); + } catch (err) { + safeSend(ws, { type: 'error', message: err.message || 'Hiba tortent.' }); + } + }); + + ws.on('close', async () => { + if (ws.tableId && ws.user) { + const table = getTableById(ws.tableId); + if (table) { + await table.leave(ws.user.id, 'disconnect'); + table.removeClient(ws); + } + } + }); + }); +}