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:
Michess
2026-04-13 10:02:52 +02:00
parent 29968f023d
commit 7dac042d73
28 changed files with 1760 additions and 89 deletions

View 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();
}
};

View 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();
}
};

View 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;
};