statki/index.js
MaciejkaG 3f97b240aa Major changes
- Improved e-mail display on some of the major clients that do not handle flexboxes properly (Gmail)
- Added ratelimiting (with Redis store)
- Sessions are now stored in Redis
- Added some serious login problem display
- Improved match saving into the MySQL database
- Login system enhancements
- Minor design improvements
- Bug fixes
- User security improvements
2024-03-18 18:43:13 +01:00

562 lines
20 KiB
JavaScript

import 'dotenv/config';
const PORT = parseInt(process.env.port);
import express from 'express';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { v4 as uuidv4, validate } from 'uuid';
import session from "express-session";
import { engine } from 'express-handlebars';
import { createClient } from 'redis';
import * as bships from './utils/battleships.js';
import { MailAuth } from './utils/auth.js';
import { rateLimit } from 'express-rate-limit';
import { RedisStore as LimiterRedisStore } from 'rate-limit-redis';
import SessionRedisStore from 'connect-redis';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.engine('handlebars', engine());
app.set('view engine', 'handlebars');
app.set('views', './views');
const server = createServer(app);
const io = new Server(server);
const redis = createClient();
redis.on('error', err => console.log('Redis Client Error', err));
await redis.connect();
const limiter = rateLimit({
windowMs: 40 * 1000,
limit: 100,
standardHeaders: 'draft-7',
legacyHeaders: false,
store: new LimiterRedisStore({
sendCommand: (...args) => redis.sendCommand(args),
}),
});
app.use(limiter);
const GInfo = new bships.GameInfo(redis, io);
const auth = new MailAuth(redis, {
host: process.env.mail_host,
user: process.env.mail_user,
pass: process.env.mail_pass
},
{
host: process.env.db_host,
user: process.env.db_user,
password: process.env.db_pass,
database: 'statki'
});
app.set('trust proxy', 1);
let sessionStore = new SessionRedisStore({
client: redis,
prefix: "statkiSession:",
});
const sessionMiddleware = session({
store: sessionStore,
secret: uuidv4(),
resave: true,
saveUninitialized: true,
rolling: true,
cookie: {
secure: process.env.cookie_secure === "true" ? true : false,
maxAge: 24 * 60 * 60 * 1000,
},
});
app.use(sessionMiddleware);
app.use(express.static(path.join(__dirname, 'public')));
io.engine.use(sessionMiddleware);
app.get('/', async (req, res) => {
let login = loginState(req);
if (login != 2) {
res.redirect('/login');
} else if (req.session.nickname == null) {
auth.getNickname(req.session.userId).then(nickname => {
if (nickname != null) {
req.session.nickname = nickname;
res.render('index');
} else {
res.redirect('/nickname');
}
});
} else {
res.render('index');
}
});
app.get('/login', (req, res) => {
let login = loginState(req);
if (!login) {
res.render('login');
} else if (login == 1) {
res.redirect('/auth');
} else {
res.redirect('/');
}
});
app.get('/auth', (req, res) => {
let login = loginState(req);
if (!login) { // Niezalogowany
res.redirect('/login');
} else if (login == 1) { // W trakcie autoryzacji
res.render('auth');
} else { // Zalogowany
res.redirect('/auth');
}
});
app.get('/nickname', (req, res) => {
let login = loginState(req);
if (!login) { // Niezalogowany
res.redirect('/login');
} else {
res.render('setup');
}
});
app.post('/api/login', (req, res) => {
let login = loginState(req);
if (login == 2) {
res.redirect('/');
} else if (login == 0 && req.body.email != null && validateEmail(req.body.email)) {
auth.startVerification(req.body.email).then(result => {
if (result.status === 1) {
req.session.userId = result.uid;
req.session.loggedIn = 1;
res.redirect('/auth');
} else if (result.status === -1) {
res.render("error", {
helpers: {
error: "Nie udało się zalogować",
fallback: "/login"
}
});
} else {
res.sendStatus(500);
}
});
} else {
res.render("error", {
helpers: {
error: "Niepoprawny adres e-mail",
fallback: "/login"
}
});
}
});
app.post('/api/auth', async (req, res) => {
let login = loginState(req);
if (login == 2) {
res.redirect('/');
} else if (login == 1 && req.body.code != null && req.body.code.length <= 10 && req.body.code.length >= 8) {
let finishResult = await auth.finishVerification(req.session.userId, req.body.code);
if (finishResult) {
req.session.loggedIn = 2;
res.redirect('/');
} else {
res.render("error", {
helpers: {
error: "Niepoprawny kod logowania",
fallback: "/auth"
}
});
}
} else {
res.render("error", {
helpers: {
error: "Niepoprawny kod logowania",
fallback: "/login"
}
});
}
});
app.post('/api/nickname', (req, res) => {
if (loginState(req) == 2 && req.session.nickname == null && req.body.nickname != null && 3 <= req.body.nickname.length && req.body.nickname.length <= 16) {
req.session.nickname = req.body.nickname;
req.session.activeGame = null;
auth.setNickname(req.session.userId, req.body.nickname).then(() => {
res.redirect('/');
});
} else {
res.sendStatus(400);
}
});
app.get('/game', async (req, res) => {
const game = await redis.json.get(`game:${req.query.id}`);
if (req.session.nickname == null) {
res.redirect('/setup');
} else if (req.query.id == null || game == null || game.state == 'expired' || req.session.activeGame == null) {
res.status(400).send('badGameId');
} else {
res.render('board');
}
});
app.get("/*", (req, res) => {
res.redirect("/?path=" + req.originalUrl);
});
io.on('connection', async (socket) => {
const req = socket.request;
const session = socket.request.session;
socket.session = session;
if (session.nickname==null) {
socket.disconnect();
return;
}
if (!await GInfo.isPlayerInGame(socket)) {
socket.on('whats my nick', (callback) => {
callback(session.nickname);
});
socket.on('create lobby', (callback) => {
if (socket.rooms.size === 1) {
let id = genID();
callback({
status: "ok",
gameCode: id
});
socket.join(id);
} else {
callback({
status: "alreadyInLobby",
gameCode: socket.rooms[1]
});
}
});
socket.on('join lobby', (msg, callback) => {
if (io.sockets.adapter.rooms.get(msg) == null || io.sockets.adapter.rooms.get(msg).size > 1) {
callback({
status: "bad_id"
});
} else {
let opp = io.sockets.sockets.get(io.sockets.adapter.rooms.get(msg).values().next().value);
if (opp.request.session.userId == session.userId) {
callback({
status: "cantJoinYourself",
});
return;
}
if (socket.rooms.size === 1) {
io.to(msg).emit("joined", session.nickname); // Wyślij hostowi powiadomienie o dołączającym graczu
// Zmienna opp zawiera socket hosta
// let opp = io.sockets.sockets.get(io.sockets.adapter.rooms.get(msg).values().next().value);
let oppNickname = opp.request.session.nickname;
socket.join(msg); // Dołącz gracza do grupy
callback({
status: "ok",
oppNickname: oppNickname,
}); // Wyślij dołączonemu graczowi odpowiedź
// Teraz utwórz objekt partii w trakcie w bazie Redis
const gameId = uuidv4();
redis.json.set(`game:${gameId}`, '$', {
hostId: opp.request.session.id,
state: "pregame",
startTs: (new Date()).getTime() / 1000,
boards: [
{ // typ 2 to trójmasztowiec pozycja i obrót na planszy które pola zostały trafione
ships: [], // zawiera np. {type: 2, posX: 3, posY: 4, rot: 2, hits: [false, false, true]}
// pozycja na planszy czy strzał miał udział w zatopieniu statku?
shots: [], // zawiera np. {posX: 3, posY: 5}
stats: {
shots: 0,
hits: 0,
placedShips: 0,
sunkShips: 0,
},
},
{
ships: [],
shots: [],
stats: {
shots: 0,
hits: 0,
placedShips: 0,
sunkShips: 0,
},
}
],
nextPlayer: 0,
});
req.session.reload((err) => {
if (err) return socket.disconnect();
req.session.activeGame = gameId;
req.session.save();
});
const oppReq = opp.request;
oppReq.session.reload((err) => {
if (err) return socket.disconnect();
oppReq.session.activeGame = gameId;
oppReq.session.save();
});
io.to(msg).emit("gameReady", gameId);
io.sockets.adapter.rooms.get(msg).forEach((sid) => {
const s = io.sockets.sockets.get(sid);
s.leave(msg);
});
} else {
callback({
status: "alreadyInLobby",
});
}
}
});
socket.on('leave lobby', (callback) => {
if (socket.rooms.size === 2) {
socket.leave(socket.rooms[1]);
io.to(socket.rooms[1]).emit("player left");
callback({
status: "ok"
});
} else {
callback({
status: "youreNotInLobby"
});
}
});
socket.on('disconnecting', () => {
if (bships.isPlayerInRoom(socket)) {
io.to(socket.rooms[1]).emit("player left");
}
});
} else {
const playerGame = await GInfo.getPlayerGameData(socket);
if (playerGame.data.state === 'pregame') {
socket.join(playerGame.id);
if (io.sockets.adapter.rooms.get(playerGame.id).size === 2) {
GInfo.resetTimer(playerGame.id);
io.to(playerGame.id).emit('players ready');
const members = [...roomMemberIterator(playerGame.id)];
for (let i = 0; i < members.length; i++) {
const sid = members[i][0];
const socket = io.sockets.sockets.get(sid);
if (socket.request.session.id === playerGame.data.hostId) {
io.to(sid).emit('player idx', 0);
} else {
io.to(sid).emit('player idx', 1);
}
}
let UTCTs = Math.floor((new Date()).getTime() / 1000 + 90);
io.to(playerGame.id).emit('turn update', { turn: 0, phase: "preparation", timerToUTC: UTCTs });
GInfo.timer(playerGame.id, 10, async () => {
const playerGame = await GInfo.getPlayerGameData(socket);
for (let i = 0; i < playerGame.data.boards.length; i++) {
const ships = playerGame.data.boards[i].ships;
if (!ships.length) {
AFKEnd(playerGame.id);
return;
}
}
GInfo.endPrepPhase(socket);
GInfo.timer(playerGame.id, 30, () => {
AFKEnd(playerGame.id);
});
});
await redis.json.set(`game:${playerGame.id}`, '$.state', "preparation");
} else if (io.sockets.adapter.rooms.get(playerGame.id).size > 2) {
socket.disconnect();
} else {
GInfo.timer(playerGame.id, 30, () => {
AFKEnd(playerGame.id);
});
}
}
socket.on('place ship', async (type, posX, posY, rot) => {
const playerGame = await GInfo.getPlayerGameData(socket);
if (playerGame.data.state === 'preparation') {
const playerShips = await GInfo.getPlayerShips(socket);
let canPlace = bships.validateShipPosition(playerShips, type, posX, posY, rot);
let shipAvailable = bships.getShipsAvailable(playerShips)[type] > 0;
if (!canPlace) {
socket.emit("toast", "Nie możesz postawić tak statku");
} else if (!shipAvailable) {
socket.emit("toast", "Nie masz już statków tego typu");
} else {
await GInfo.placeShip(socket, { type: type, posX: posX, posY: posY, rot: rot, hits: Array.from(new Array(type+1), () => false) });
socket.emit("placed ship", { type: type, posX: posX, posY: posY, rot: rot });
await GInfo.incrStat(socket, 'placedShips');
}
}
});
socket.on('remove ship', async (posX, posY) => {
const playerGame = await GInfo.getPlayerGameData(socket);
if (playerGame.data.state === 'preparation') {
const deletedShip = await GInfo.removeShip(socket, posX, posY);
socket.emit("removed ship", { posX: posX, posY: posY, type: deletedShip.type });
await GInfo.incrStat(socket, 'placedShips', -1);
}
});
socket.on('shoot', async (posX, posY) => {
const playerGame = await GInfo.getPlayerGameData(socket);
if (playerGame.data.state === 'action') {
if (bships.checkTurn(playerGame.data, socket.request.session.id)) {
const enemyIdx = socket.request.session.id === playerGame.data.hostId ? 1 : 0;
let hit = await GInfo.shootShip(socket, posX, posY);
await redis.json.arrAppend(`game:${playerGame.id}`, `.boards[${enemyIdx}].shots`, { posX: posX, posY: posY });
await GInfo.incrStat(socket, 'shots');
if (!hit.status) {
io.to(playerGame.id).emit("shot missed", enemyIdx, posX, posY);
} else if (hit.status === 1) {
io.to(playerGame.id).emit("shot hit", enemyIdx, posX, posY);
await GInfo.incrStat(socket, 'hits');
} else if (hit.status === 2) {
io.to(playerGame.id).emit("shot hit", enemyIdx, posX, posY);
await GInfo.incrStat(socket, 'hits');
io.to(playerGame.id).emit("ship sunk", enemyIdx, hit.ship);
await GInfo.incrStat(socket, 'sunkShips');
if (hit.gameFinished) {
const members = [...roomMemberIterator(playerGame.id)];
let hostSocket = io.sockets.sockets.get(members[0][0]);
let hostNickname = hostSocket.request.session.nickname;
let guestSocket = io.sockets.sockets.get(members[1][0]);
let guestNickname = guestSocket.request.session.nickname;
hostSocket.emit("game finished", !enemyIdx ? 1 : 0, guestNickname);
guestSocket.emit("game finished", !enemyIdx ? 1 : 0, hostNickname);
// const stats = await GInfo.getStats(socket);
const playerGame = await GInfo.getPlayerGameData(socket);
auth.saveMatch(playerGame.id, (new Date).getTime() / 1000 - playerGame.data.startTs, "pvp", hostSocket.request.session.userId, guestSocket.request.session.userId, playerGame.data.boards, !enemyIdx ? 1 : 0);
GInfo.resetTimer(playerGame.id);
endGame(playerGame.id, !enemyIdx ? 1 : 0);
return;
}
} else if (hit.status === -1) {
socket.emit("toast", "Już strzeliłeś w to miejsce");
return;
}
await GInfo.passTurn(socket);
GInfo.resetTimer(playerGame.id);
GInfo.timer(playerGame.id, 30, () => {
AFKEnd(playerGame.id);
});
}
}
});
socket.on('disconnecting', async () => {
const playerGame = await GInfo.getPlayerGameData(socket);
if (playerGame !== null) {
AFKEnd(playerGame.id);
await GInfo.resetTimer(playerGame.id);
}
});
}
});
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
function genID() {
return Math.floor(100000 + Math.random() * 900000).toString();
}
function resetUserGame(req) {
req.session.reload((err) => {
if (err) return socket.disconnect();
req.session.activeGame = null;
req.session.save();
});
}
function endGame(gameId) {
// const boards = redis.json.get(`game:${gameId}`, { keys: [".boards"] });
// const hostUid = redis.json.get(`game:${gameId}`, { keys: [".hostUserId"] });
// const guestUid = redis.json.get(`game:${gameId}`, { keys: [".hostUserId"] });
let iterator = roomMemberIterator(gameId);
if (iterator != null) {
const members = [...iterator];
for (let i = 0; i < members.length; i++) {
const sid = members[i][0];
const socket = io.sockets.sockets.get(sid);
resetUserGame(socket.request);
socket.leave(gameId);
}
}
redis.json.del(`game:${gameId}`);
}
function AFKEnd(gameId) {
io.to(gameId).emit("player left");
endGame(gameId);
}
function roomMemberIterator(id) {
return io.sockets.adapter.rooms.get(id) == undefined ? null : io.sockets.adapter.rooms.get(id).entries();
}
function loginState(req) {
if (req.session.loggedIn == null) {
return 0;
} else {
return req.session.loggedIn;
}
}
function validateEmail(email) {
return String(email)
.toLowerCase()
.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|.(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
);
};