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;
|
||||
};
|
||||
Reference in New Issue
Block a user