feat: Michess – Initiales Setup auf Basis von chessu
- Rebranding: chessu → Michess (Header, Footer, Metadata, Session-Cookie) - Stockfish KI-Integration: 6 Schwierigkeitsgrade (Anfänger bis Meister) - Admin-Panel: Nutzerverwaltung, Sperren/Entsperren, Git-Pull-Update - Freundessystem: Anfragen, Freundesliste, Nutzersuche - DB-Schema: role, banned, friend_request, friendship Tabellen - Docker: NAS-optimiertes docker-compose.yml mit PostgreSQL Healthcheck - Stockfish binary via apk im Alpine-Image installiert - .env.example für einfaches Deployment - scripts/update.sh für manuelles NAS-Update - Brain.md + Plan.md als Projekt-Gedächtnis Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
113
server/src/controllers/admin.controller.ts
Normal file
113
server/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { exec } from "child_process";
|
||||
import type { Request, Response } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import UserModel from "../db/models/user.model.js";
|
||||
|
||||
export const getStats = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const usersRes = await db.query(`SELECT COUNT(*) FROM "user"`);
|
||||
const gamesRes = await db.query(`SELECT COUNT(*) FROM "game"`);
|
||||
const activeUsersRes = await db.query(
|
||||
`SELECT COUNT(*) FROM "user" WHERE created_at > NOW() - INTERVAL '7 days'`
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
totalUsers: parseInt(usersRes.rows[0].count),
|
||||
totalGames: parseInt(gamesRes.rows[0].count),
|
||||
newUsersThisWeek: parseInt(activeUsersRes.rows[0].count)
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const listUsers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
const users = await UserModel.getAllUsers(limit, offset, search);
|
||||
const countRes = await db.query(
|
||||
search
|
||||
? `SELECT COUNT(*) FROM "user" WHERE name ILIKE $1 OR email ILIKE $1`
|
||||
: `SELECT COUNT(*) FROM "user"`,
|
||||
search ? [`%${search}%`] : []
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
users,
|
||||
total: parseInt(countRes.rows[0].count),
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUser = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { role, banned } = req.body;
|
||||
|
||||
// Prevent admin from banning themselves
|
||||
if (req.session.user?.id === id && banned === true) {
|
||||
res.status(400).json({ message: "Cannot ban yourself." });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await UserModel.adminUpdate(id, { role, banned });
|
||||
if (!updated) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.status(200).json(updated);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteUser = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
if (req.session.user?.id === id) {
|
||||
res.status(400).json({ message: "Cannot delete your own account." });
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = await UserModel.remove(id);
|
||||
if (!removed) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.status(200).json(removed);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const triggerUpdate = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const workdir = process.env.APP_DIR || "/opt/michess";
|
||||
|
||||
exec(`cd ${workdir} && git pull origin main 2>&1`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
res.status(500).json({ message: "Git pull failed", output: stderr || err.message });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({
|
||||
message: "Update successful. Restart the container to apply changes.",
|
||||
output: stdout
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
192
server/src/controllers/friends.controller.ts
Normal file
192
server/src/controllers/friends.controller.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import UserModel from "../db/models/user.model.js";
|
||||
|
||||
export const sendRequest = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const fromId = req.session.user?.id as number;
|
||||
const { username } = req.body;
|
||||
|
||||
if (!username) {
|
||||
res.status(400).json({ message: "Username required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = await UserModel.findByNameEmail({ name: username, email: username });
|
||||
if (!targets || !targets.length) {
|
||||
res.status(404).json({ message: "User not found." });
|
||||
return;
|
||||
}
|
||||
const toId = targets[0].id as number;
|
||||
|
||||
if (toId === fromId) {
|
||||
res.status(400).json({ message: "Cannot add yourself." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check already friends
|
||||
const existing = await db.query(
|
||||
`SELECT id FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||
[fromId, toId]
|
||||
);
|
||||
if (existing.rowCount) {
|
||||
res.status(409).json({ message: "Already friends." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check existing request
|
||||
const existingReq = await db.query(
|
||||
`SELECT id, status FROM "friend_request" WHERE (from_id=$1 AND to_id=$2) OR (from_id=$2 AND to_id=$1)`,
|
||||
[fromId, toId]
|
||||
);
|
||||
if (existingReq.rowCount) {
|
||||
res.status(409).json({ message: "Request already exists." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO "friend_request"(from_id, to_id) VALUES($1, $2) RETURNING id`,
|
||||
[fromId, toId]
|
||||
);
|
||||
|
||||
res.status(201).json({ id: result.rows[0].id, toName: targets[0].name });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const getRequests = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
|
||||
const incoming = await db.query(
|
||||
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||
u.name as from_name
|
||||
FROM "friend_request" fr
|
||||
JOIN "user" u ON u.id = fr.from_id
|
||||
WHERE fr.to_id=$1 AND fr.status='pending'
|
||||
ORDER BY fr.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const outgoing = await db.query(
|
||||
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||
u.name as to_name
|
||||
FROM "friend_request" fr
|
||||
JOIN "user" u ON u.id = fr.to_id
|
||||
WHERE fr.from_id=$1 AND fr.status='pending'
|
||||
ORDER BY fr.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
incoming: incoming.rows,
|
||||
outgoing: outgoing.rows
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const respondToRequest = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
const requestId = parseInt(req.params.id);
|
||||
const { action } = req.body; // "accept" | "reject"
|
||||
|
||||
if (action !== "accept" && action !== "reject") {
|
||||
res.status(400).json({ message: "Invalid action." });
|
||||
return;
|
||||
}
|
||||
|
||||
const reqRow = await db.query(
|
||||
`SELECT * FROM "friend_request" WHERE id=$1 AND to_id=$2 AND status='pending'`,
|
||||
[requestId, userId]
|
||||
);
|
||||
|
||||
if (!reqRow.rowCount) {
|
||||
res.status(404).json({ message: "Request not found." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { from_id, to_id } = reqRow.rows[0];
|
||||
|
||||
await db.query(`UPDATE "friend_request" SET status=$1 WHERE id=$2`, [
|
||||
action === "accept" ? "accepted" : "rejected",
|
||||
requestId
|
||||
]);
|
||||
|
||||
if (action === "accept") {
|
||||
const [a, b] = from_id < to_id ? [from_id, to_id] : [to_id, from_id];
|
||||
await db.query(
|
||||
`INSERT INTO "friendship"(user_id_1, user_id_2) VALUES($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[a, b]
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: action === "accept" ? "Friend added!" : "Request rejected." });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const getFriends = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
f.id,
|
||||
CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END as friend_id,
|
||||
u.name as friend_name,
|
||||
u.wins, u.losses, u.draws,
|
||||
f.created_at
|
||||
FROM "friendship" f
|
||||
JOIN "user" u ON u.id = CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END
|
||||
WHERE f.user_id_1=$1 OR f.user_id_2=$1
|
||||
ORDER BY u.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.status(200).json(result.rows);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFriend = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
const friendId = parseInt(req.params.id);
|
||||
|
||||
await db.query(
|
||||
`DELETE FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||
[userId, friendId]
|
||||
);
|
||||
|
||||
res.status(200).json({ message: "Friend removed." });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const searchUsers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const q = req.query.q as string;
|
||||
if (!q || q.length < 2) {
|
||||
res.status(400).json({ message: "Query too short." });
|
||||
return;
|
||||
}
|
||||
|
||||
const users = await UserModel.searchByName(q, 10);
|
||||
res.status(200).json(users);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
127
server/src/controllers/stockfish.controller.ts
Normal file
127
server/src/controllers/stockfish.controller.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
|
||||
|
||||
export interface StockfishLevel {
|
||||
level: number;
|
||||
name: string;
|
||||
skillLevel: number;
|
||||
depth: number;
|
||||
moveTime: number; // ms
|
||||
}
|
||||
|
||||
export const AI_LEVELS: StockfishLevel[] = [
|
||||
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 },
|
||||
{ level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 },
|
||||
{ level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 },
|
||||
{ level: 4, name: "Fortgeschritten",skillLevel: 14, depth: 10, moveTime: 1000 },
|
||||
{ level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 },
|
||||
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
|
||||
];
|
||||
|
||||
export class StockfishEngine {
|
||||
private process: ChildProcessWithoutNullStreams | null = null;
|
||||
private buffer = "";
|
||||
private resolvers: Map<string, (move: string) => void> = new Map();
|
||||
private ready = false;
|
||||
|
||||
constructor() {
|
||||
this.start();
|
||||
}
|
||||
|
||||
private start() {
|
||||
// Try system stockfish first, then fallback to npm stockfish
|
||||
const binary = process.env.STOCKFISH_PATH || "stockfish";
|
||||
try {
|
||||
this.process = spawn(binary, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
} catch {
|
||||
console.error("Failed to start stockfish binary, trying node-stockfish fallback");
|
||||
return;
|
||||
}
|
||||
|
||||
this.process.stdout.on("data", (data: Buffer) => {
|
||||
this.buffer += data.toString();
|
||||
const lines = this.buffer.split("\n");
|
||||
this.buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
this.handleLine(line.trim());
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr.on("data", (data: Buffer) => {
|
||||
console.error("Stockfish stderr:", data.toString());
|
||||
});
|
||||
|
||||
this.process.on("exit", (code) => {
|
||||
console.log("Stockfish exited with code", code);
|
||||
this.ready = false;
|
||||
});
|
||||
|
||||
this.send("uci");
|
||||
}
|
||||
|
||||
private send(cmd: string) {
|
||||
if (this.process?.stdin.writable) {
|
||||
this.process.stdin.write(cmd + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string) {
|
||||
if (line === "uciok") {
|
||||
this.ready = true;
|
||||
this.send("isready");
|
||||
}
|
||||
if (line === "readyok") {
|
||||
// engine ready
|
||||
}
|
||||
if (line.startsWith("bestmove ")) {
|
||||
const parts = line.split(" ");
|
||||
const move = parts[1];
|
||||
// resolve any pending resolver
|
||||
const [key] = this.resolvers.entries().next().value ?? [];
|
||||
if (key) {
|
||||
this.resolvers.get(key)?.(move === "(none)" ? "" : move);
|
||||
this.resolvers.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBestMove(fen: string, level: StockfishLevel): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.process || !this.ready) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
this.resolvers.set(id, resolve);
|
||||
|
||||
this.send("ucinewgame");
|
||||
this.send(`setoption name Skill Level value ${level.skillLevel}`);
|
||||
this.send(`position fen ${fen}`);
|
||||
this.send(`go depth ${level.depth} movetime ${level.moveTime}`);
|
||||
|
||||
// Timeout safety
|
||||
setTimeout(() => {
|
||||
if (this.resolvers.has(id)) {
|
||||
this.resolvers.delete(id);
|
||||
resolve("");
|
||||
}
|
||||
}, level.moveTime + 5000);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.send("quit");
|
||||
this.process?.kill();
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton engine instance
|
||||
let engineInstance: StockfishEngine | null = null;
|
||||
|
||||
export const getEngine = (): StockfishEngine => {
|
||||
if (!engineInstance) {
|
||||
engineInstance = new StockfishEngine();
|
||||
}
|
||||
return engineInstance;
|
||||
};
|
||||
@@ -6,11 +6,13 @@ export const INIT_TABLES = /* sql */ `
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(128) UNIQUE NOT NULL,
|
||||
email VARCHAR(128),
|
||||
email VARCHAR(128) UNIQUE,
|
||||
password TEXT,
|
||||
role VARCHAR(16) DEFAULT 'user',
|
||||
wins INTEGER DEFAULT 0,
|
||||
losses INTEGER DEFAULT 0,
|
||||
draws INTEGER DEFAULT 0,
|
||||
banned BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "game" (
|
||||
@@ -22,7 +24,28 @@ export const INIT_TABLES = /* sql */ `
|
||||
white_name VARCHAR(32),
|
||||
black_id INT REFERENCES "user",
|
||||
black_name VARCHAR(32),
|
||||
vs_ai BOOLEAN DEFAULT FALSE,
|
||||
ai_level INTEGER,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "friend_request" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
to_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
status VARCHAR(16) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(from_id, to_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "friendship" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id_1 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
user_id_2 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id_1, user_id_2)
|
||||
);
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role VARCHAR(16) DEFAULT 'user';
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS banned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS vs_ai BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS ai_level INTEGER;
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,7 @@ export const create = async (user: User, password: string) => {
|
||||
|
||||
try {
|
||||
const res = await db.query(
|
||||
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws`,
|
||||
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws, role, banned`,
|
||||
[user.name, user.email || null, password]
|
||||
);
|
||||
return res.rows[0] as User;
|
||||
@@ -24,11 +24,11 @@ export const findById = async (id: number) => {
|
||||
}
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, email, wins, losses, draws FROM "user" WHERE id=$1`,
|
||||
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" WHERE id=$1`,
|
||||
[id]
|
||||
);
|
||||
if (res.rowCount) {
|
||||
return res.rows[0] as User;
|
||||
return res.rows[0] as User & { banned?: boolean };
|
||||
} else return null;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
@@ -37,14 +37,13 @@ export const findById = async (id: number) => {
|
||||
};
|
||||
|
||||
export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
|
||||
// if user is not specified, get all users
|
||||
if (!user) {
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, email, wins, losses, draws FROM "user" LIMIT $1`,
|
||||
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" LIMIT $1`,
|
||||
[limit ?? 10]
|
||||
);
|
||||
return res.rows as (User & { password?: string })[];
|
||||
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
@@ -53,12 +52,25 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
|
||||
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, email, wins, losses, draws${
|
||||
`SELECT id, name, email, wins, losses, draws, role, banned${
|
||||
includePassword ? `, password` : ""
|
||||
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
||||
[user.name, user.email, limit ?? 1]
|
||||
);
|
||||
return res.rows as (User & { password?: string })[];
|
||||
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const searchByName = async (query: string, limit = 10) => {
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, wins, losses, draws FROM "user" WHERE name ILIKE $1 LIMIT $2`,
|
||||
[`%${query}%`, limit]
|
||||
);
|
||||
return res.rows as User[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
@@ -71,11 +83,11 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
||||
}
|
||||
|
||||
try {
|
||||
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws`;
|
||||
let values = [updatedUser.name, updatedUser.email, id];
|
||||
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, role, banned`;
|
||||
let values: (string | number | undefined)[] = [updatedUser.name, updatedUser.email, id];
|
||||
|
||||
if (updatedUser.password) {
|
||||
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws`;
|
||||
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws, role, banned`;
|
||||
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
|
||||
}
|
||||
const res = await db.query(query, values);
|
||||
@@ -86,6 +98,58 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
||||
}
|
||||
};
|
||||
|
||||
export const adminUpdate = async (id: number, fields: { role?: string; banned?: boolean }) => {
|
||||
try {
|
||||
const setClauses: string[] = [];
|
||||
const values: (string | boolean | number)[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (fields.role !== undefined) {
|
||||
setClauses.push(`role=$${paramIndex++}`);
|
||||
values.push(fields.role);
|
||||
}
|
||||
if (fields.banned !== undefined) {
|
||||
setClauses.push(`banned=$${paramIndex++}`);
|
||||
values.push(fields.banned);
|
||||
}
|
||||
|
||||
if (!setClauses.length) return null;
|
||||
|
||||
values.push(id);
|
||||
const res = await db.query(
|
||||
`UPDATE "user" SET ${setClauses.join(", ")} WHERE id=$${paramIndex} RETURNING id, name, email, role, banned`,
|
||||
values
|
||||
);
|
||||
return res.rows[0] as User & { banned?: boolean };
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllUsers = async (limit = 50, offset = 0, search?: string) => {
|
||||
try {
|
||||
let query = `SELECT id, name, email, wins, losses, draws, role, banned, created_at FROM "user"`;
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (search) {
|
||||
query += ` WHERE name ILIKE $1 OR email ILIKE $1`;
|
||||
values.push(`%${search}%`);
|
||||
query += ` ORDER BY created_at DESC LIMIT $${values.length + 1} OFFSET $${values.length + 2}`;
|
||||
values.push(limit, offset);
|
||||
} else {
|
||||
query += ` ORDER BY created_at DESC LIMIT $1 OFFSET $2`;
|
||||
values.push(limit, offset);
|
||||
}
|
||||
|
||||
const res = await db.query(query, values);
|
||||
return res.rows as (User & { banned?: boolean; created_at?: string })[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (id: number) => {
|
||||
if (id === 0) {
|
||||
return null;
|
||||
@@ -106,7 +170,10 @@ const UserModel = {
|
||||
create,
|
||||
findById,
|
||||
findByNameEmail,
|
||||
searchByName,
|
||||
update,
|
||||
adminUpdate,
|
||||
getAllUsers,
|
||||
remove
|
||||
};
|
||||
|
||||
|
||||
21
server/src/middleware/auth.ts
Normal file
21
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||
res.status(401).json({ message: "Not authenticated." });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||
res.status(401).json({ message: "Not authenticated." });
|
||||
return;
|
||||
}
|
||||
if (req.session.user.role !== "admin") {
|
||||
res.status(403).json({ message: "Admin access required." });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -27,7 +27,7 @@ const sessionMiddleware = session({
|
||||
secret: process.env.SESSION_SECRET || "make sure to change this!",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: "chessu",
|
||||
name: "michess",
|
||||
proxy: true,
|
||||
cookie: {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
|
||||
15
server/src/routes/admin.route.ts
Normal file
15
server/src/routes/admin.route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from "express";
|
||||
import * as admin from "../controllers/admin.controller.js";
|
||||
import { requireAdmin } from "../middleware/auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.get("/stats", admin.getStats);
|
||||
router.get("/users", admin.listUsers);
|
||||
router.patch("/users/:id", admin.updateUser);
|
||||
router.delete("/users/:id", admin.deleteUser);
|
||||
router.post("/update", admin.triggerUpdate);
|
||||
|
||||
export default router;
|
||||
31
server/src/routes/ai.route.ts
Normal file
31
server/src/routes/ai.route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/move", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { fen, level } = req.body;
|
||||
|
||||
if (!fen || typeof fen !== "string") {
|
||||
res.status(400).json({ message: "FEN required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const aiLevel = AI_LEVELS.find((l) => l.level === (level || 3)) || AI_LEVELS[2];
|
||||
const engine = getEngine();
|
||||
const move = await engine.getBestMove(fen, aiLevel);
|
||||
|
||||
res.status(200).json({ move: move || null });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/levels", (_req: Request, res: Response) => {
|
||||
res.status(200).json(AI_LEVELS.map(({ level, name }) => ({ level, name })));
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
server/src/routes/friends.route.ts
Normal file
15
server/src/routes/friends.route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from "express";
|
||||
import * as friends from "../controllers/friends.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get("/", friends.getFriends);
|
||||
router.post("/request", friends.sendRequest);
|
||||
router.get("/requests", friends.getRequests);
|
||||
router.patch("/requests/:id", friends.respondToRequest);
|
||||
router.delete("/:id", friends.removeFriend);
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import admin from "./admin.route.js";
|
||||
import ai from "./ai.route.js";
|
||||
import auth from "./auth.route.js";
|
||||
import friends from "./friends.route.js";
|
||||
import games from "./games.route.js";
|
||||
import users from "./users.route.js";
|
||||
|
||||
@@ -9,5 +12,8 @@ const router = Router();
|
||||
router.use("/games", games);
|
||||
router.use("/auth", auth);
|
||||
router.use("/users", users);
|
||||
router.use("/admin", admin);
|
||||
router.use("/friends", friends);
|
||||
router.use("/ai", ai);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import * as controller from "../controllers/users.controller.js";
|
||||
import { searchUsers } from "../controllers/friends.controller.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/search", searchUsers);
|
||||
router.route("/:name").get(controller.getUserProfile);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -20,11 +20,25 @@ const server = createServer(app);
|
||||
|
||||
// database
|
||||
await db.connect();
|
||||
db.query(INIT_TABLES, (err) => {
|
||||
db.query(INIT_TABLES, async (err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
} else {
|
||||
console.log("Tables initialized");
|
||||
console.log("Michess tables initialized");
|
||||
|
||||
// Set admin role for ADMIN_EMAIL if configured
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
if (adminEmail) {
|
||||
try {
|
||||
await db.query(
|
||||
`UPDATE "user" SET role='admin' WHERE email=$1 AND role='user'`,
|
||||
[adminEmail]
|
||||
);
|
||||
console.log(`Admin role ensured for: ${adminEmail}`);
|
||||
} catch (e) {
|
||||
console.error("Failed to set admin role:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,6 +47,20 @@ app.use(cors(corsConfig));
|
||||
app.use(express.json());
|
||||
app.set("trust proxy", 1);
|
||||
app.use(session);
|
||||
|
||||
// Ban check middleware
|
||||
app.use("/v1", (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.session.user?.id && typeof req.session.user.id === "number") {
|
||||
const user = req.session.user as { banned?: boolean };
|
||||
if (user.banned) {
|
||||
req.session.destroy(() => {});
|
||||
res.status(403).json({ message: "Your account has been banned." });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use("/v1", routes);
|
||||
|
||||
// socket.io
|
||||
@@ -41,8 +69,8 @@ io.use((socket, next) => {
|
||||
session(socket.request as Request, {} as Response, next as NextFunction);
|
||||
});
|
||||
io.use((socket, next) => {
|
||||
const session = socket.request.session;
|
||||
if (session && session.user) {
|
||||
const sess = socket.request.session;
|
||||
if (sess && sess.user) {
|
||||
next();
|
||||
} else {
|
||||
console.log("io.use: no session");
|
||||
@@ -53,5 +81,5 @@ initSocket();
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
server.listen(port, () => {
|
||||
console.log(`chessu api server listening on :${port}`);
|
||||
console.log(`Michess API server listening on :${port}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user