Compare commits

..

10 Commits

Author SHA1 Message Date
Michess
7dac042d73 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>
2026-04-13 10:02:52 +02:00
Nathaniel Tampus
29968f023d Merge pull request #29 from hriprsd/main
adding dockerfile and compose files for hosting chessu in container
2024-10-15 08:52:37 +08:00
hprasa638
8fdbc660a5 adding dockerfile and compose files for hosting chessu in container 2024-10-14 16:12:06 +05:30
Nathaniel Tampus
2ac873c5b1 update to node 20 & pnpm 9 2024-07-14 14:35:37 +08:00
Nathaniel Tampus
a13ff39fd5 add sponsorship 2024-07-14 14:35:01 +08:00
Nathaniel Tampus
b55c382983 update lockfile 2024-04-01 15:05:33 +08:00
Nathaniel Tampus
bf1b2159a7 update readme 2024-04-01 15:02:13 +08:00
Nathaniel Tampus
d5008a88fa format 2024-01-15 19:55:20 +08:00
Nathaniel Tampus
72c14280f0 update readme 2024-01-15 19:54:58 +08:00
Nathaniel Tampus
394644bc81 update deps 2024-01-15 19:54:50 +08:00
38 changed files with 5397 additions and 2945 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.github
CODE_OF_CONDUCT.md
CONTRIBUTING.md
LICENSE
README.md
docker-compose.yml
Dockerfile
LICENSE

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# ============================================
# Michess Konfigurationsdatei
# Kopiere diese Datei nach .env und passe sie an
# ============================================
# Datenbank
POSTGRES_DB=michess
POSTGRES_USER=michess
POSTGRES_PASSWORD=sicheres_passwort_hier
# Session (mindestens 32 zufällige Zeichen)
SESSION_SECRET=aendere_mich_in_produktion_mindestens_32_zeichen
# CORS: URL unter der das Frontend erreichbar ist
CORS_ORIGIN=http://deine-nas-ip:3000
# Admin: Diese E-Mail-Adresse bekommt automatisch Admin-Rechte
ADMIN_EMAIL=admin@example.com
# Ports (optional, Standard: 3000 + 3001)
MICHESS_PORT=3000
MICHESS_API_PORT=3001
# App-URL (für Next.js Metadaten)
NEXT_PUBLIC_APP_URL=http://deine-nas-ip:3000
NEXT_PUBLIC_API_URL=http://deine-nas-ip:3001

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: [dotnize]

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ yarn.lock
node_modules
.pnp
.pnpm-store
.pnp.js
.next

127
Brain.md Normal file
View File

@@ -0,0 +1,127 @@
# Brain.md — Michess Project Memory
> Dieses Dokument ist mein aktives Gedächtnis während der Entwicklung von Michess.
> Ich denke hier, notiere Entscheidungen, Probleme und Fortschritt.
---
## Projektziel
Michess = Lichess-inspirierte Schachplattform, self-hosted auf einer NAS, in Docker, mit Stockfish-Engine.
---
## Tech Stack (Entschieden)
| Schicht | Technologie | Grund |
|---|---|---|
| Frontend | Next.js 14 + React + Tailwind + daisyUI | Von chessu geerbt, modern, SSR-fähig |
| Backend | Node.js + Express + Socket.io | Von chessu geerbt, real-time-ready |
| Datenbank | PostgreSQL | Von chessu geerbt, solide |
| Auth | express-session + argon2 | Bereits vorhanden, sicher |
| Schach-Logik | chess.js | Bereits vorhanden |
| Schachbrett UI | react-chessboard | Bereits vorhanden |
| Stockfish | stockfish npm-Paket | Einfache Integration im Backend |
| Container | Docker Compose | NAS-Deployment |
| Package Manager | pnpm Workspaces | Monorepo-Setup |
---
## Basis-Projekt
- **Geclont von:** `dotnize/chessu` (MIT-Lizenz)
- **Warum:** TypeScript, React/Next.js, Express, Socket.io, PostgreSQL, chess.js — perfekter Ausgangspunkt
- **Was chessu schon hat:**
- User-Accounts (name, email, password, wins/losses/draws)
- Session-Auth mit argon2-Passwort-Hashing
- Real-time Multiplayer via Socket.io
- Spiele mit PGN-Speicherung
- Public Games Liste
- Archiv (gespielte Partien)
- User-Profilseite
---
## Was ich bauen muss (Delta zu chessu)
1. **Stockfish AI-Gegner** — verschiedene Schwierigkeitsgrade (Level 1-8, ELO-basiert)
2. **Admin-Panel** — Nutzerverwaltung, Git-Pull-Button, Statistiken
3. **Freundessystem** — Freundschaftsanfragen, Freundesliste, Freunde herausfordern
4. **Branding** — Alles auf "Michess" umbenennen
5. **Docker NAS-Deployment** — docker-compose für NAS, Auto-Update-Script
6. **DB-Schema Erweiterungen** — admin-Flag, friends-Tabelle, friend_requests-Tabelle
7. **E-Mail-basierte Registrierung** — (email-Feld schon da, braucht UI-Flow: email → unique username wählen)
---
## Architektur-Entscheidungen
### Stockfish im Backend
- Stockfish läuft als Child-Process auf dem Server (NAS)
- Kommunikation über UCI-Protokoll
- Schwierigkeitsgrade via `Skill Level` (0-20) und `Depth`
- Pro aktiver AI-Partie: eigener Stockfish-Prozess (oder Pool)
### Admin-Rolle
- `role` Spalte in `user`-Tabelle (default: 'user', admin: 'admin')
- Erster User mit email aus `ADMIN_EMAIL` env var wird automatisch Admin
- Admin-Middleware schützt `/v1/admin/*` Routen
### Friends-System
- `friends`-Tabelle: user_id_1, user_id_2 (symmetrisch)
- `friend_requests`-Tabelle: from_id, to_id, status (pending/accepted/rejected)
- Nutzer suchen via Username
### Git-Auto-Update (Admin-Feature)
- Admin-Route: `POST /v1/admin/update`
- Führt `git pull` im Container-Verzeichnis aus
- Triggert danach Neustart (via restart-policy des Containers)
- Update-Script wird ins Docker-Image eingebaut
### Docker NAS-Setup
- `docker-compose.yml` mit Services: `michess-app` + `postgres`
- Watchtower oder einfaches update-script für automatische Updates
- Alle Credentials via `.env`-Datei
---
## Aktueller Status
- [x] Basis-Projekt geclont (chessu)
- [x] Brain.md erstellt
- [x] Plan.md erstellt
- [ ] Git remote auf `git.mischlabs.de` gesetzt
- [ ] Projekt auf "Michess" umbenannt
- [ ] DB-Schema erweitert (admin, friends, friend_requests)
- [ ] Stockfish integriert
- [ ] Admin-Panel gebaut
- [ ] Friends-System gebaut
- [ ] Docker NAS-Deployment finalisiert
- [ ] Alles committed & gepusht
---
## Bekannte Probleme / Offene Fragen
- chessu's Dockerfile nutzt `CMD ["start"]` — muss für Michess angepasst werden (Server + Client separat oder zusammen)
- Stockfish binary muss im Docker-Image vorhanden sein (Alpine Linux: `apk add stockfish` oder npm-Paket)
- Admin-Git-Pull: funktioniert nur wenn Container read-write Zugriff auf sein eigenes Verzeichnis hat — besser: Host-Script via webhook triggern
---
## Lessons Learned
*(wird im Verlauf befüllt)*
---
## Nächste Schritte (immer aktuell)
1. Git remote setzen → push
2. Umbenennen chessu → Michess
3. DB-Schema erweitern
4. Stockfish backend einbauen
5. Admin-Panel UI + Backend
6. Friends-System
7. Docker finalisieren
8. Issues aus Git bearbeiten

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:lts-alpine3.20
# Install stockfish and git
RUN apk update && apk add --no-cache stockfish git
ENV PNPM_HOME=/usr/local/bin
WORKDIR /opt/michess/
COPY . .
RUN corepack enable && \
corepack prepare pnpm@latest --activate && \
pnpm config set store-dir /opt/michess/.pnpm-store && \
pnpm install --frozen-lockfile
# Build both client and server
RUN pnpm build:server && pnpm build:client
EXPOSE 3000 3001
ENTRYPOINT ["pnpm"]
CMD ["start"]

189
Plan.md Normal file
View File

@@ -0,0 +1,189 @@
# Plan.md — Michess Entwicklungsplan
---
## Vision
**Michess** ist eine selbst-gehostete Schachplattform für eine private Community, inspiriert von Lichess.org.
Läuft als Docker-Container auf einer Synology/QNAP NAS, komplett ohne Cloud-Abhängigkeit.
---
## Phase 1: Foundation (Basis)
### 1.1 Projekt-Setup
- [x] chessu (MIT) als Basis klonen
- [x] Brain.md & Plan.md erstellen
- [ ] Git remote auf `https://git.mischlabs.de/MrDiderot/Michess.git` setzen
- [ ] Initaler Push (chessu-Basis + eigene Dateien)
### 1.2 Rebranding chessu → Michess
- [ ] `package.json` Namen anpassen
- [ ] README durch Michess-README ersetzen
- [ ] Alle Titel/Meta-Tags im Frontend ändern
- [ ] Logo-Platzhalter anpassen
### 1.3 Docker NAS-Deployment
- [ ] `docker-compose.yml` überarbeiten (michess-app, postgres, eigene Secrets)
- [ ] `.env.example` erstellen mit allen benötigten Variablen
- [ ] Stockfish in Docker-Image einbauen (`apk add stockfish`)
- [ ] Update-Script: `scripts/update.sh` (git pull + docker restart)
- [ ] Deployment-README schreiben (einziger Befehl für NAS)
---
## Phase 2: Datenbank-Erweiterungen
### 2.1 Schema-Änderungen
```sql
-- User: Admin-Rolle hinzufügen
ALTER TABLE "user" ADD COLUMN role VARCHAR(16) DEFAULT 'user';
-- Freundschaftsanfragen
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', -- pending | accepted | rejected
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(from_id, to_id)
);
-- Freunde (bidirektional, normalisiert als kleinere_id + größere_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)
);
```
---
## Phase 3: Stockfish AI-Integration
### 3.1 Backend
- [ ] `stockfish` npm-Paket als Server-Dependency
- [ ] `server/src/controllers/stockfish.controller.ts` — Stockfish-Prozess-Manager
- [ ] Schwierigkeitsgrade:
| Level | Name | Stockfish Skill | Depth |
|---|---|---|---|
| 1 | Anfänger | 0 | 1 |
| 2 | Leicht | 3 | 3 |
| 3 | Mittel | 8 | 5 |
| 4 | Fortgeschritten | 14 | 10 |
| 5 | Experte | 18 | 15 |
| 6 | Meister | 20 | 20 |
- [ ] Socket-Events: `ai:move`, `ai:game_start`, `ai:game_end`
### 3.2 Frontend
- [ ] "Gegen KI spielen" Button auf Startseite
- [ ] Schwierigkeitsgrad-Auswahl Modal
- [ ] KI-Spielpartie-UI (keine zweite Socket-Session nötig, nur lokal)
---
## Phase 4: Admin-Panel
### 4.1 Backend
- [ ] `isAdmin` Middleware für `/v1/admin/*`
- [ ] `GET /v1/admin/users` — alle Nutzer auflisten (paginiert)
- [ ] `PATCH /v1/admin/users/:id` — Nutzer sperren/entsperren, Rolle ändern
- [ ] `DELETE /v1/admin/users/:id` — Nutzer löschen
- [ ] `POST /v1/admin/update` — git pull + trigger restart
- [ ] `GET /v1/admin/stats` — Nutzerzahl, Spiele, etc.
### 4.2 Frontend
- [ ] `/admin` Route (nur für Admins sichtbar)
- [ ] Dashboard mit Statistiken
- [ ] Nutzertabelle mit Such-/Filterfunktion
- [ ] "Website aktualisieren" Button mit Bestätigung
- [ ] Nutzer sperren/entsperren/löschen
### 4.3 Erster Admin
- [ ] `ADMIN_EMAIL` env-Variable
- [ ] Beim Server-Start: wenn User mit dieser E-Mail existiert → Role = 'admin'
- [ ] Alternativ: `ADMIN_SETUP_TOKEN` für initiale Admin-Einrichtung
---
## Phase 5: Freundessystem
### 5.1 Backend
- [ ] `POST /v1/friends/request` — Anfrage senden (via Username)
- [ ] `GET /v1/friends/requests` — eigene Anfragen (eingehend + ausgehend)
- [ ] `PATCH /v1/friends/requests/:id` — annehmen/ablehnen
- [ ] `GET /v1/friends` — eigene Freundesliste
- [ ] `DELETE /v1/friends/:id` — Freundschaft beenden
- [ ] `GET /v1/users/search?q=` — Nutzer suchen
### 5.2 Frontend
- [ ] Freundesliste im User-Profil
- [ ] "Freund hinzufügen" Button auf fremden Profilseiten
- [ ] Benachrichtigungs-Badge bei ausstehenden Anfragen
- [ ] "Freund herausfordern" → direktes Spiel erstellen
---
## Phase 6: Registrierungs-Flow
### Aktueller chessu-Flow:
`name + email + password` → Account erstellt
### Gewünschter Michess-Flow:
1. `email + password` → Account erstellt (noch kein Nutzername)
2. Weitergeleitet zu "Nutzername wählen" (einmalig, nicht änderbar)
3. Nutzername prüfen: verfügbar? gültig? → bestätigen
4. Account aktiv
- [ ] `name` Feld nullable machen in DB (bis Schritt 2)
- [ ] `POST /v1/auth/choose-username` Endpoint
- [ ] "Username wählen"-Seite im Frontend
---
## Phase 7: Sonstige Verbesserungen
- [ ] Zeitkontrolle für Partien (Bullet/Blitz/Rapid/Classical)
- [ ] Rating-System (Elo) für Nutzer-vs-Nutzer
- [ ] Rematch-Funktion
- [ ] Spectator-Verbesserungen (Live-Zuschauer)
- [ ] Mobile-Optimierung
---
## Deployment-Anleitung (Ziel-Zustand)
```bash
# Einmalige Erstinstallation auf der NAS:
git clone https://git.mischlabs.de/MrDiderot/Michess.git michess
cd michess
cp .env.example .env
# .env anpassen (Passwörter, Admin-Email, etc.)
docker compose up -d
# Update (manuell oder per Admin-Panel):
./scripts/update.sh
# oder via Admin-Panel "Website aktualisieren" Button
```
---
## Git-Strategie
- `main` — Production-Branch (immer deploybar)
- Features werden direkt auf main entwickelt (kleines Team)
- Issues aus `git.mischlabs.de` als Feature-Tracker
- Commit-Messages: `feat:`, `fix:`, `admin:`, `docker:`, `ui:` Prefixe
---
## Prioritäten (Reihenfolge)
1. **P0**: Foundation + Docker + Git (läuft auf NAS)
2. **P0**: Stockfish AI (Kernfeature)
3. **P1**: Admin-Panel (Verwaltbarkeit)
4. **P1**: Freundessystem (Social)
5. **P2**: Registrierungs-Flow verfeinern
6. **P3**: Zeitkontrolle + Rating

View File

@@ -1,9 +1,3 @@
Notes from the maintainer:
> I started this project when I was still new to TS and full-stack development. Bad practices, confusing patterns, and messed up types are everywhere. Suggestions for improvements are very much welcome!
> The client is currently being rewritten at the `frontend-refactor` branch ([#24](https://github.com/dotnize/chessu/pull/24)). Contributions to `main` that involve the frontend may get overwritten, but I'll try to cherry-pick those that can be applied to the new branch.
<h1 align="center">
<img src="./assets/chessu.png" alt="chessu" height="128" />
</h1>
@@ -14,7 +8,7 @@ Notes from the maintainer:
<img src="https://img.shields.io/github/last-commit/dotnize/chessu?style=for-the-badge" alt="Last commit" />
</p>
<p align="center">Yet another Chess web app. Live demo at <a href="https://ches.su">ches.su</a>.</p>
<p align="center">Yet another Chess web app.
<p align="center">
<img src="./assets/demo.jpg" alt="chessu" width="640" />
@@ -31,12 +25,12 @@ Built with Next.js 14, Tailwind CSS + daisyUI, react-chessboard, chess.js, Expre
## Development
> Node.js 18 or newer is recommended.
> Node.js 20 or newer is recommended.
This project is structured as a monorepo using **pnpm** workspaces, separated into three packages:
- `client` - Next.js application for the front-end, deployed to [ches.su](https://ches.su) via Vercel.
- `server` - Node/Express.js application for the back-end, deployed to [server.ches.su](https://server.ches.su) via Railway.
- `client` - Next.js application for the front-end, ~~deployed to ches.su via Vercel~~.
- `server` - Node/Express.js application for the back-end, ~~deployed to server.ches.su via Railway~~.
- `types` - Shared type definitions required by the client and server.
### Getting started
@@ -54,6 +48,32 @@ This project is structured as a monorepo using **pnpm** workspaces, separated in
- To run the frontend and backend servers separately, use `pnpm dev:client` and `pnpm dev:server`, respectively.
5. You can now access the frontend at http://localhost:3000 and the backend at http://localhost:3001.
## Running chessu with Docker
To build the project with Docker, you can use the provided `Dockerfile`.
```sh
docker build -t chessu .
```
This command will build the Docker image with the name `chessu`. You can then run the image with the following command:
```sh
docker run -p 3000:3000 -p 3001:3001 chessu
```
Once built, to start the project with POSTGRES, you can use the provided `docker-compose.yml` file.
```sh
docker-compose up
```
Please make sure to modify the values in the `server/.env` file to match the values in the `docker-compose.yml` file or vice versa.
The entrypoint for the Docker image is set to run pnpm.
The Dockerfile's `CMD` instruction is set to run the project in production mode.
If you want to run the project in development mode, you can override the `CMD` instruction by running the following command:
```sh
docker run -p 3000:3000 -p 3001:3001 chessu dev # runs both client and server in development mode
docker run -p 3000:3000 -p 3001:3001 chessu dev:client # runs only the client in development mode
docker run -p 3000:3000 -p 3001:3001 chessu dev:server # runs only the server in development mode
```
## Contributing
Please read our [Contributing Guidelines](./CONTRIBUTING.md) before starting a pull request.

View File

@@ -10,24 +10,24 @@
"lint": "next lint"
},
"dependencies": {
"@tabler/icons-react": "^2.41.0",
"chess.js": "1.0.0-beta.6",
"next": "^14.0.3",
"react": "^18.2.0",
"@tabler/icons-react": "^2.47.0",
"chess.js": "1.0.0-beta.8",
"next": "^14.2.5",
"react": "^18.3.1",
"react-chessboard": "^2.1.3",
"react-dom": "^18.2.0",
"socket.io-client": "^4.7.2"
"react-dom": "^18.3.1",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@chessu/types": "*",
"@types/node": "^18.18.10",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"autoprefixer": "^10.4.16",
"@chessu/types": "workspace:*",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"daisyui": "^2.52.0",
"eslint-config-next": "^14.0.3",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2"
"eslint-config-next": "^14.2.5",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.3"
}
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
interface UserRow {
id: number;
name: string;
email: string;
wins: number;
losses: number;
draws: number;
role: string;
banned: boolean;
created_at: string;
}
interface Stats {
totalUsers: number;
totalGames: number;
newUsersThisWeek: number;
}
export default function AdminPage() {
const { user } = useSession();
const router = useRouter();
const [stats, setStats] = useState<Stats | null>(null);
const [users, setUsers] = useState<UserRow[]>([]);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [updateLog, setUpdateLog] = useState("");
const [updating, setUpdating] = useState(false);
const limit = 20;
useEffect(() => {
if (user === null) router.push("/");
if (user && user.role !== "admin") router.push("/");
}, [user, router]);
useEffect(() => {
fetchStats();
fetchUsers();
}, [page, search]);
const fetchStats = async () => {
const res = await fetch(`${API_URL}/v1/admin/stats`, { credentials: "include" });
if (res.ok) setStats(await res.json());
};
const fetchUsers = async () => {
const params = new URLSearchParams({ limit: String(limit), offset: String(page * limit) });
if (search) params.set("search", search);
const res = await fetch(`${API_URL}/v1/admin/users?${params}`, { credentials: "include" });
if (res.ok) {
const data = await res.json();
setUsers(data.users);
setTotal(data.total);
}
};
const toggleBan = async (id: number, banned: boolean) => {
await fetch(`${API_URL}/v1/admin/users/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ banned: !banned })
});
fetchUsers();
};
const toggleAdmin = async (id: number, role: string) => {
const newRole = role === "admin" ? "user" : "admin";
await fetch(`${API_URL}/v1/admin/users/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole })
});
fetchUsers();
};
const deleteUser = async (id: number, name: string) => {
if (!confirm(`Nutzer "${name}" wirklich löschen?`)) return;
await fetch(`${API_URL}/v1/admin/users/${id}`, { method: "DELETE", credentials: "include" });
fetchUsers();
fetchStats();
};
const triggerUpdate = async () => {
if (!confirm("Website jetzt aus Git aktualisieren?")) return;
setUpdating(true);
setUpdateLog("Update wird ausgeführt...");
const res = await fetch(`${API_URL}/v1/admin/update`, { method: "POST", credentials: "include" });
const data = await res.json();
setUpdateLog(data.output || data.message || "Fertig.");
setUpdating(false);
};
if (!user || user.role !== "admin") {
return <div className="flex items-center justify-center w-full py-20 text-xl">Kein Zugriff.</div>;
}
return (
<div className="w-full max-w-6xl py-8 px-4 flex flex-col gap-8">
<h1 className="text-3xl font-bold">Admin-Panel</h1>
{/* Stats */}
{stats && (
<div className="stats stats-horizontal shadow w-full">
<div className="stat">
<div className="stat-title">Nutzer gesamt</div>
<div className="stat-value">{stats.totalUsers}</div>
</div>
<div className="stat">
<div className="stat-title">Spiele gesamt</div>
<div className="stat-value">{stats.totalGames}</div>
</div>
<div className="stat">
<div className="stat-title">Neue Nutzer (7 Tage)</div>
<div className="stat-value">{stats.newUsersThisWeek}</div>
</div>
</div>
)}
{/* Update Button */}
<div className="card bg-base-200 shadow p-6 flex flex-col gap-3">
<h2 className="text-xl font-semibold">Website aktualisieren</h2>
<p className="text-sm opacity-70">Führt <code>git pull</code> im App-Verzeichnis aus. Container muss danach neugestartet werden.</p>
<button
className="btn btn-primary w-fit"
onClick={triggerUpdate}
disabled={updating}
>
{updating ? <span className="loading loading-spinner loading-sm" /> : null}
Jetzt aktualisieren
</button>
{updateLog && (
<pre className="bg-base-300 rounded p-3 text-xs overflow-auto max-h-40">{updateLog}</pre>
)}
</div>
{/* User Management */}
<div className="card bg-base-200 shadow p-6 flex flex-col gap-4">
<h2 className="text-xl font-semibold">Nutzerverwaltung</h2>
<input
type="text"
placeholder="Nutzer suchen..."
className="input input-bordered w-full max-w-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
<div className="overflow-x-auto">
<table className="table table-zebra w-full text-sm">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>E-Mail</th>
<th>W/L/D</th>
<th>Rolle</th>
<th>Status</th>
<th>Registriert</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={u.banned ? "opacity-50" : ""}>
<td>{u.id}</td>
<td className="font-medium">{u.name}</td>
<td>{u.email}</td>
<td>{u.wins}/{u.losses}/{u.draws}</td>
<td>
<span className={`badge badge-sm ${u.role === "admin" ? "badge-warning" : "badge-ghost"}`}>
{u.role}
</span>
</td>
<td>
<span className={`badge badge-sm ${u.banned ? "badge-error" : "badge-success"}`}>
{u.banned ? "Gesperrt" : "Aktiv"}
</span>
</td>
<td>{new Date(u.created_at).toLocaleDateString("de-DE")}</td>
<td className="flex gap-1 flex-wrap">
<button
className="btn btn-xs btn-outline"
onClick={() => toggleBan(u.id, u.banned)}
>
{u.banned ? "Entsperren" : "Sperren"}
</button>
<button
className="btn btn-xs btn-outline btn-warning"
onClick={() => toggleAdmin(u.id, u.role)}
disabled={u.id === (user.id as number)}
>
{u.role === "admin" ? "Admin entfernen" : "Admin machen"}
</button>
<button
className="btn btn-xs btn-outline btn-error"
onClick={() => deleteUser(u.id, u.name)}
disabled={u.id === (user.id as number)}
>
Löschen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex gap-2 items-center">
<button className="btn btn-sm" disabled={page === 0} onClick={() => setPage(p => p - 1)}>
&larr; Zurück
</button>
<span className="text-sm">Seite {page + 1} &mdash; {total} Nutzer gesamt</span>
<button
className="btn btn-sm"
disabled={(page + 1) * limit >= total}
onClick={() => setPage(p => p + 1)}
>
Weiter &rarr;
</button>
</div>
</div>
</div>
);
}

187
client/src/app/ai/page.tsx Normal file
View File

@@ -0,0 +1,187 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Chessboard } from "react-chessboard";
import { Chess } from "chess.js";
import { useRouter } from "next/navigation";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
const AI_LEVELS = [
{ level: 1, name: "Anfänger" },
{ level: 2, name: "Leicht" },
{ level: 3, name: "Mittel" },
{ level: 4, name: "Fortgeschritten" },
{ level: 5, name: "Experte" },
{ level: 6, name: "Meister" },
];
export default function AiGamePage() {
const { user } = useSession();
const router = useRouter();
const [selectedLevel, setSelectedLevel] = useState(3);
const [gameStarted, setGameStarted] = useState(false);
const [game, setGame] = useState(new Chess());
const [playerColor, setPlayerColor] = useState<"white" | "black">("white");
const [status, setStatus] = useState("");
const [thinking, setThinking] = useState(false);
const [lastMove, setLastMove] = useState<{ from: string; to: string } | null>(null);
const getStatus = useCallback((g: Chess) => {
if (g.isCheckmate()) return g.turn() === "w" ? "Schwarz gewinnt! Schachmatt." : "Weiß gewinnt! Schachmatt.";
if (g.isDraw()) return "Unentschieden!";
if (g.isCheck()) return g.turn() === "w" ? "Weiß ist im Schach!" : "Schwarz ist im Schach!";
return g.turn() === "w" ? "Weiß am Zug" : "Schwarz am Zug";
}, []);
const requestAiMove = useCallback(async (fen: string, level: number) => {
setThinking(true);
try {
const res = await fetch(`${API_URL}/v1/ai/move`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fen, level })
});
if (res.ok) {
const { move } = await res.json();
if (move) {
setGame((prev) => {
const newGame = new Chess(prev.fen());
const result = newGame.move({ from: move.slice(0, 2), to: move.slice(2, 4), promotion: move[4] || "q" });
if (result) setLastMove({ from: result.from, to: result.to });
setStatus(getStatus(newGame));
return newGame;
});
}
}
} catch (e) {
console.error("AI move error:", e);
}
setThinking(false);
}, [getStatus]);
const onDrop = useCallback((sourceSquare: string, targetSquare: string) => {
if (thinking) return false;
const currentTurn = game.turn() === "w" ? "white" : "black";
if (currentTurn !== playerColor) return false;
const newGame = new Chess(game.fen());
let move = null;
try {
move = newGame.move({ from: sourceSquare, to: targetSquare, promotion: "q" });
} catch {
return false;
}
if (!move) return false;
setLastMove({ from: move.from, to: move.to });
setGame(newGame);
setStatus(getStatus(newGame));
if (!newGame.isGameOver()) {
requestAiMove(newGame.fen(), selectedLevel);
}
return true;
}, [game, playerColor, thinking, selectedLevel, getStatus, requestAiMove]);
const startGame = (color: "white" | "black") => {
const newGame = new Chess();
setGame(newGame);
setPlayerColor(color);
setGameStarted(true);
setLastMove(null);
setStatus(getStatus(newGame));
if (color === "black") {
requestAiMove(newGame.fen(), selectedLevel);
}
};
const resetGame = () => {
setGameStarted(false);
setGame(new Chess());
setLastMove(null);
setStatus("");
};
const customSquareStyles: Record<string, React.CSSProperties> = {};
if (lastMove) {
customSquareStyles[lastMove.from] = { backgroundColor: "rgba(255, 255, 0, 0.4)" };
customSquareStyles[lastMove.to] = { backgroundColor: "rgba(255, 255, 0, 0.4)" };
}
if (!gameStarted) {
return (
<div className="flex flex-col items-center gap-8 py-10 w-full max-w-xl px-4">
<h1 className="text-3xl font-bold">Gegen KI spielen</h1>
<div className="card bg-base-200 shadow p-6 w-full flex flex-col gap-5">
<div>
<h2 className="text-lg font-semibold mb-3">Schwierigkeitsgrad</h2>
<div className="flex flex-wrap gap-2">
{AI_LEVELS.map((l) => (
<button
key={l.level}
className={`btn ${selectedLevel === l.level ? "btn-primary" : "btn-outline"}`}
onClick={() => setSelectedLevel(l.level)}
>
{l.level}. {l.name}
</button>
))}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Farbe wählen</h2>
<div className="flex gap-4">
<button className="btn btn-outline flex-1" onClick={() => startGame("white")}>
Als Weiß spielen
</button>
<button className="btn btn-outline flex-1" onClick={() => startGame("black")}>
Als Schwarz spielen
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col lg:flex-row gap-6 py-6 w-full max-w-5xl px-4 items-start justify-center">
<div className="w-full max-w-[560px]">
<Chessboard
position={game.fen()}
onPieceDrop={onDrop}
boardOrientation={playerColor}
customSquareStyles={customSquareStyles}
arePiecesDraggable={!game.isGameOver() && !thinking}
/>
</div>
<div className="flex flex-col gap-4 min-w-[200px]">
<div className="card bg-base-200 shadow p-4">
<p className="font-semibold text-sm opacity-70">Niveau: {AI_LEVELS.find(l => l.level === selectedLevel)?.name}</p>
<p className="font-semibold text-sm opacity-70">Du spielst: {playerColor === "white" ? "Weiß ♔" : "Schwarz ♚"}</p>
</div>
<div className={`card shadow p-4 ${game.isGameOver() ? "bg-warning text-warning-content" : "bg-base-200"}`}>
<p className="font-semibold">{status}</p>
{thinking && <p className="text-sm opacity-70 mt-1">KI denkt nach...</p>}
</div>
<button className="btn btn-outline" onClick={resetGame}>Neues Spiel</button>
<div className="card bg-base-200 shadow p-3 max-h-64 overflow-y-auto">
<h3 className="text-sm font-semibold mb-2">Züge</h3>
<div className="text-xs font-mono">
{game.history().map((move, i) => (
<span key={i}>{i % 2 === 0 ? `${Math.floor(i / 2) + 1}. ` : ""}{move} </span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { API_URL } from "@/config";
import { useSession } from "@/context/session";
interface Friend {
id: number;
friend_id: number;
friend_name: string;
wins: number;
losses: number;
draws: number;
created_at: string;
}
interface FriendRequest {
id: number;
from_id: number;
to_id: number;
from_name?: string;
to_name?: string;
status: string;
created_at: string;
}
export default function FriendsPage() {
const { user } = useSession();
const router = useRouter();
const [friends, setFriends] = useState<Friend[]>([]);
const [requests, setRequests] = useState<{ incoming: FriendRequest[]; outgoing: FriendRequest[] }>({ incoming: [], outgoing: [] });
const [addUsername, setAddUsername] = useState("");
const [addMsg, setAddMsg] = useState("");
useEffect(() => {
if (user === null) router.push("/");
if (user && user.id) {
fetchFriends();
fetchRequests();
}
}, [user, router]);
const fetchFriends = async () => {
const res = await fetch(`${API_URL}/v1/friends`, { credentials: "include" });
if (res.ok) setFriends(await res.json());
};
const fetchRequests = async () => {
const res = await fetch(`${API_URL}/v1/friends/requests`, { credentials: "include" });
if (res.ok) setRequests(await res.json());
};
const sendRequest = async () => {
setAddMsg("");
const res = await fetch(`${API_URL}/v1/friends/request`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: addUsername })
});
const data = await res.json();
setAddMsg(res.ok ? `Anfrage an "${data.toName}" gesendet!` : data.message || "Fehler.");
if (res.ok) { setAddUsername(""); fetchRequests(); }
};
const respond = async (id: number, action: "accept" | "reject") => {
await fetch(`${API_URL}/v1/friends/requests/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action })
});
fetchFriends();
fetchRequests();
};
const removeFriend = async (friendId: number, name: string) => {
if (!confirm(`"${name}" als Freund entfernen?`)) return;
await fetch(`${API_URL}/v1/friends/${friendId}`, { method: "DELETE", credentials: "include" });
fetchFriends();
};
if (!user?.id || typeof user.id === "string") {
return <div className="flex items-center justify-center w-full py-20">Bitte einloggen.</div>;
}
return (
<div className="w-full max-w-3xl py-8 px-4 flex flex-col gap-8">
<h1 className="text-3xl font-bold">Freunde</h1>
{/* Add friend */}
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Freund hinzufügen</h2>
<div className="flex gap-2">
<input
type="text"
placeholder="Nutzername"
className="input input-bordered flex-1"
value={addUsername}
onChange={(e) => setAddUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendRequest()}
/>
<button className="btn btn-primary" onClick={sendRequest} disabled={!addUsername.trim()}>
Anfrage senden
</button>
</div>
{addMsg && <p className="text-sm">{addMsg}</p>}
</div>
{/* Incoming requests */}
{requests.incoming.length > 0 && (
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Eingehende Anfragen ({requests.incoming.length})</h2>
{requests.incoming.map((r) => (
<div key={r.id} className="flex items-center justify-between gap-2">
<Link href={`/user/${r.from_name}`} className="font-medium link link-hover">
{r.from_name}
</Link>
<div className="flex gap-2">
<button className="btn btn-sm btn-success" onClick={() => respond(r.id, "accept")}>Annehmen</button>
<button className="btn btn-sm btn-outline btn-error" onClick={() => respond(r.id, "reject")}>Ablehnen</button>
</div>
</div>
))}
</div>
)}
{/* Outgoing requests */}
{requests.outgoing.length > 0 && (
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Gesendete Anfragen</h2>
{requests.outgoing.map((r) => (
<div key={r.id} className="flex items-center gap-2 opacity-70">
<span></span>
<Link href={`/user/${r.to_name}`} className="link link-hover">{r.to_name}</Link>
<span className="badge badge-sm">ausstehend</span>
</div>
))}
</div>
)}
{/* Friends list */}
<div className="card bg-base-200 shadow p-5 flex flex-col gap-3">
<h2 className="text-lg font-semibold">Meine Freunde ({friends.length})</h2>
{friends.length === 0 && <p className="opacity-60">Noch keine Freunde. Füge jemanden hinzu!</p>}
{friends.map((f) => (
<div key={f.id} className="flex items-center justify-between gap-2">
<Link href={`/user/${f.friend_name}`} className="font-medium link link-hover">
{f.friend_name}
</Link>
<span className="text-sm opacity-60">{f.wins}W / {f.losses}L / {f.draws}D</span>
<button className="btn btn-xs btn-outline btn-error" onClick={() => removeFriend(f.friend_id, f.friend_name)}>
Entfernen
</button>
</div>
))}
</div>
</div>
);
}

View File

@@ -9,18 +9,17 @@ import AuthModal from "@/components/auth/AuthModal";
import ContextProvider from "@/context/ContextProvider";
export const metadata = {
title: "chessu",
description: "Play Chess online.",
title: "Michess",
description: "Schach spielen lokal, privat, kostenlos.",
openGraph: {
title: "chessu",
description: "Play Chess online.",
url: "https://ches.su",
siteName: "chessu",
locale: "en_US",
title: "Michess",
description: "Schach spielen lokal, privat, kostenlos.",
siteName: "Michess",
locale: "de_DE",
type: "website"
},
robots: {
index: true,
index: false,
follow: false,
nocache: true,
noarchive: true
@@ -33,12 +32,12 @@ export const metadata = {
apple: { url: "/apple-touch-icon.png", sizes: "180x180" }
},
manifest: "/site.webmanifest",
metadataBase: new URL(process.env.VERCEL ? "https://ches.su" : "http://localhost:3000")
metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000")
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className="overflow-x-hidden">
<html lang="de" className="overflow-x-hidden">
<body className="overflow-x-hidden">
<ContextProvider>
<Header />
@@ -52,7 +51,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<Footer />
{/* next/script issue: https://github.com/vercel/next.js/issues/43402 */}
<script
id="load-theme"
dangerouslySetInnerHTML={{

View File

@@ -1,24 +1,54 @@
"use client";
import CreateGame from "@/components/home/CreateGame";
import JoinGame from "@/components/home/JoinGame";
import PublicGames from "@/components/home/PublicGames/PublicGames";
import Link from "next/link";
import { useSession } from "@/context/session";
export const revalidate = 0;
export default function Home() {
const { user } = useSession();
return (
<div className="flex w-full flex-wrap items-center justify-center gap-8 px-4 py-10 lg:gap-16 ">
<div className="flex w-full flex-wrap items-start justify-center gap-8 px-4 py-10 lg:gap-16">
<PublicGames />
<div className="flex flex-col items-center gap-4">
{/* Against AI */}
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Join from invite</h2>
<h2 className="mb-4 text-xl font-bold leading-tight">Gegen KI spielen</h2>
<Link href="/ai" className="btn btn-primary w-full max-w-xs">
Gegen Stockfish spielen
</Link>
</div>
<div className="divider divider-vertical">oder</div>
{/* Friends */}
{user?.id && typeof user.id === "number" && (
<>
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Freunde</h2>
<Link href="/friends" className="btn btn-outline w-full max-w-xs">
👥 Freunde verwalten
</Link>
</div>
<div className="divider divider-vertical">oder</div>
</>
)}
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Per Einladung beitreten</h2>
<JoinGame />
</div>
<div className="divider divider-vertical">or</div>
<div className="divider divider-vertical">oder</div>
<div className="flex flex-col items-center">
<h2 className="mb-4 text-xl font-bold leading-tight">Create game</h2>
<h2 className="mb-4 text-xl font-bold leading-tight">Spiel erstellen</h2>
<CreateGame />
</div>
</div>

View File

@@ -1,30 +1,17 @@
import { IconBrandGithub } from "@tabler/icons-react";
export default function Footer() {
return (
<footer className="footer border-base-300 dark:border-neutral text-base-content mx-1 mt-4 w-auto grid-flow-col items-center justify-between border-t-2 p-4 md:mx-16 lg:mx-40">
<div className="items-center">
<p>
&copy; 2023{" "}
<a
href="https://n9ze.com"
target="_blank"
rel="noopener noreferrer"
className="link-hover"
>
nize
</a>
</p>
<p>&copy; {new Date().getFullYear()} Michess &mdash; Dein privates Schachbrett</p>
</div>
<div className="items-center">
<a
href="https://github.com/dotnize/chessu"
href="https://git.mischlabs.de/MrDiderot/Michess"
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost btn-sm gap-1 normal-case"
>
<IconBrandGithub className="inline-block" size={16} />
GitHub
Gitea
</a>
</div>
</footer>

View File

@@ -1,8 +1,13 @@
import { IconExternalLink, IconUser } from "@tabler/icons-react";
"use client";
import { IconUser, IconShield } from "@tabler/icons-react";
import Link from "next/link";
import ThemeToggle from "./ThemeToggle";
import { useSession } from "@/context/session";
export default function Header() {
const { user } = useSession();
return (
<header className="navbar border-base-300 dark:border-neutral mx-1 w-auto justify-center border-b-2 md:mx-16 lg:mx-40">
<div className="flex flex-1 items-center gap-2">
@@ -20,20 +25,16 @@ export default function Header() {
>
<path d="M237.84 383.74c13.61 23.9 35.9 48.09 32.29 80.27H17.83c-3.62-32.18 21.67-56.37 35.28-80.27h184.73zM69.68 120.41h148.64c7.51 0 13.66 6.18 13.66 13.66s-6.18 13.66-13.66 13.66H69.68c-7.48 0-13.66-6.15-13.66-13.66s6.15-13.66 13.66-13.66zM120.55 0h48.95v56.29c0 3.87 3.18 7.04 7.04 7.04h24.32c3.86 0 6.32-3.23 7.04-7.04L218.59 0h43.56L244.7 85.8c-4.01 12.59-13.6 18.96-28.35 19.63H66.56c-12.93-.28-20.33-6.82-22.54-19.63L25.85 0h45.62l10.69 56.29c.71 3.81 3.17 7.04 7.03 7.04h24.32c3.86 0 7.04-3.17 7.04-7.04V0zM70.94 162.75c-1.5 60.45-7.75 119.42-22.87 158.47h191.86c-17.59-44.3-24.65-102.49-26.68-158.47H70.94zM46.28 336.2h195.44c8.94 0 16.26 7.36 16.26 16.26v.01c0 8.9-7.36 16.26-16.26 16.26H46.28c-8.9 0-16.26-7.32-16.26-16.26v-.01c0-8.94 7.32-16.26 16.26-16.26zM16.82 479.03h254.36c9.25 0 16.82 7.57 16.82 16.81v.01c0 9.25-7.57 16.81-16.82 16.81H16.82C7.57 512.66 0 505.1 0 495.85v-.01c0-9.24 7.57-16.81 16.82-16.81z" />
</svg>
chessu
Michess
</Link>
<a
title="Project roadmap"
className="badge badge-sm badge-secondary gap-0.5"
href="https://github.com/users/dotnize/projects/2"
target="_blank"
rel="noopener noreferrer"
>
pre-alpha
<IconExternalLink size={12} />
</a>
</div>
<div className="flex-none">
<div className="flex-none gap-1">
{user?.role === "admin" && (
<Link href="/admin" className="btn btn-ghost btn-sm gap-1 normal-case" title="Admin-Panel">
<IconShield size={16} />
Admin
</Link>
)}
<ThemeToggle />
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
<div className="w-10 rounded-full">

View File

@@ -1,7 +1,13 @@
import type { User } from "@chessu/types";
import { createContext, Dispatch, SetStateAction } from "react";
import { createContext, Dispatch, SetStateAction, useContext } from "react";
export const SessionContext = createContext<{
user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
setUser: Dispatch<SetStateAction<User | null>>;
} | null>(null);
export const useSession = () => {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error("useSession must be used within ContextProvider");
return ctx;
};

64
docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
version: "3.8"
services:
michess:
build: .
image: michess:latest
container_name: michess
restart: unless-stopped
environment:
NODE_ENV: production
# PostgreSQL connection (uses 'db' service below)
PGHOST: db
PGPORT: 5432
PGDATABASE: ${POSTGRES_DB:-michess}
PGUSER: ${POSTGRES_USER:-michess}
PGPASSWORD: ${POSTGRES_PASSWORD:-changeme}
# App config
PORT: 3001
SESSION_SECRET: ${SESSION_SECRET:-change-this-secret-in-production}
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000}
# Admin setup: user with this email gets admin role on startup
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Stockfish binary path (stockfish is installed in the image)
STOCKFISH_PATH: stockfish
# Git update path (used by admin panel update button)
APP_DIR: /opt/michess
ports:
- "${MICHESS_PORT:-3000}:3000"
- "${MICHESS_API_PORT:-3001}:3001"
depends_on:
db:
condition: service_healthy
volumes:
- app_pnpm:/opt/michess/.pnpm-store
networks:
- michess_net
db:
image: postgres:16-alpine
container_name: michess_db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-michess}
POSTGRES_USER: ${POSTGRES_USER:-michess}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- michess_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-michess}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
driver: local
app_pnpm:
driver: local
networks:
michess_net:
driver: bridge

View File

@@ -4,8 +4,8 @@
"author": "dotnize",
"license": "MIT",
"scripts": {
"install:client": "pnpm install --filter client --config.dedupe-peer-dependents=false",
"install:server": "pnpm install --filter server --config.dedupe-peer-dependents=false",
"install:client": "pnpm install --filter client",
"install:server": "pnpm install --filter server",
"dev": "concurrently \"pnpm --filter client dev\" \"pnpm --filter server dev\"",
"dev:client": "pnpm --filter client dev",
"dev:server": "pnpm --filter server dev",
@@ -20,12 +20,12 @@
},
"devDependencies": {
"concurrently": "^8.2.2",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.1.0",
"prettier-plugin-tailwindcss": "^0.5.7"
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.5"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}

6351
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

27
scripts/update.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/sh
# Michess Update-Script
# Auf der NAS ausführen um die App auf den neuesten Stand zu bringen
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SCRIPT_DIR"
echo "=== Michess Update ==="
echo "Verzeichnis: $SCRIPT_DIR"
echo ""
echo "1. Änderungen aus Git holen..."
git pull origin main
echo ""
echo "2. Docker Image neu bauen..."
docker compose build michess
echo ""
echo "3. Container neu starten..."
docker compose up -d michess
echo ""
echo "✓ Update abgeschlossen!"
echo " Container läuft: $(docker compose ps michess --format 'table {{.Status}}')"

View File

@@ -10,33 +10,33 @@
"dev": "tsc-watch --noClear --onSuccess \"node dist/server.js\""
},
"dependencies": {
"argon2": "^0.31.2",
"chess.js": "1.0.0-beta.6",
"argon2": "^0.40.3",
"chess.js": "1.0.0-beta.8",
"connect-pg-simple": "^9.0.1",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"nanoid": "^5.0.3",
"pg": "^8.11.3",
"socket.io": "^4.7.2",
"xss": "^1.0.14"
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-session": "^1.18.0",
"nanoid": "^5.0.7",
"pg": "^8.12.0",
"socket.io": "^4.7.5",
"xss": "^1.0.15"
},
"devDependencies": {
"@chessu/types": "*",
"@chessu/types": "workspace:*",
"@types/connect-pg-simple": "^7.0.3",
"@types/cors": "^2.8.16",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/node": "^18.18.10",
"@types/pg": "^8.10.9",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"tsc-watch": "^6.0.4",
"typescript": "^5.2.2"
"@types/express-session": "^1.18.0",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"tsc-watch": "^6.2.0",
"typescript": "^5.5.3"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"optionalDependencies": {
"bufferutil": "^4.0.8",

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

View File

@@ -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;
`;

View File

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

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

View File

@@ -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

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

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

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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}`);
});

24
types/index.d.ts vendored
View File

@@ -12,6 +12,8 @@ export interface Game {
observers?: User[];
startedAt?: number;
endedAt?: number;
vsAi?: boolean;
aiLevel?: number;
}
export interface User {
@@ -21,8 +23,30 @@ export interface User {
wins?: number;
losses?: number;
draws?: number;
role?: "user" | "admin";
// mainly for players, not spectators
connected?: boolean;
disconnectedOn?: number;
}
export interface FriendRequest {
id?: number;
fromId?: number;
toId?: number;
fromName?: string;
toName?: string;
status?: "pending" | "accepted" | "rejected";
createdAt?: string;
}
export interface Friendship {
id?: number;
userId?: number;
friendId?: number;
friendName?: string;
wins?: number;
losses?: number;
draws?: number;
createdAt?: string;
}