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:
26
.env.example
Normal file
26
.env.example
Normal 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
|
||||||
127
Brain.md
Normal file
127
Brain.md
Normal 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
|
||||||
20
Dockerfile
20
Dockerfile
@@ -1,17 +1,23 @@
|
|||||||
FROM node:lts-alpine3.20
|
FROM node:lts-alpine3.20
|
||||||
|
|
||||||
|
# Install stockfish and git
|
||||||
|
RUN apk update && apk add --no-cache stockfish git
|
||||||
|
|
||||||
ENV PNPM_HOME=/usr/local/bin
|
ENV PNPM_HOME=/usr/local/bin
|
||||||
|
|
||||||
WORKDIR /opt/chessu/
|
WORKDIR /opt/michess/
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN apk update && \
|
RUN corepack enable && \
|
||||||
corepack enable && corepack prepare pnpm@latest --activate && \
|
corepack prepare pnpm@latest --activate && \
|
||||||
pnpm config set store-dir /opt/chessu/.pnpm-store && \
|
pnpm config set store-dir /opt/michess/.pnpm-store && \
|
||||||
pnpm install -g pnpm@latest && \
|
pnpm install --frozen-lockfile
|
||||||
pnpm install --unsafe-perm
|
|
||||||
|
# Build both client and server
|
||||||
|
RUN pnpm build:server && pnpm build:client
|
||||||
|
|
||||||
|
EXPOSE 3000 3001
|
||||||
|
|
||||||
ENTRYPOINT ["pnpm"]
|
ENTRYPOINT ["pnpm"]
|
||||||
|
|
||||||
CMD ["start"]
|
CMD ["start"]
|
||||||
|
|||||||
189
Plan.md
Normal file
189
Plan.md
Normal 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
|
||||||
234
client/src/app/admin/page.tsx
Normal file
234
client/src/app/admin/page.tsx
Normal 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)}>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
<span className="text-sm">Seite {page + 1} — {total} Nutzer gesamt</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
disabled={(page + 1) * limit >= total}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
client/src/app/ai/page.tsx
Normal file
187
client/src/app/ai/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
client/src/app/friends/page.tsx
Normal file
162
client/src/app/friends/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,18 +9,17 @@ import AuthModal from "@/components/auth/AuthModal";
|
|||||||
import ContextProvider from "@/context/ContextProvider";
|
import ContextProvider from "@/context/ContextProvider";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "chessu",
|
title: "Michess",
|
||||||
description: "Play Chess online.",
|
description: "Schach spielen – lokal, privat, kostenlos.",
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "chessu",
|
title: "Michess",
|
||||||
description: "Play Chess online.",
|
description: "Schach spielen – lokal, privat, kostenlos.",
|
||||||
url: "https://ches.su",
|
siteName: "Michess",
|
||||||
siteName: "chessu",
|
locale: "de_DE",
|
||||||
locale: "en_US",
|
|
||||||
type: "website"
|
type: "website"
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: false,
|
||||||
follow: false,
|
follow: false,
|
||||||
nocache: true,
|
nocache: true,
|
||||||
noarchive: true
|
noarchive: true
|
||||||
@@ -33,12 +32,12 @@ export const metadata = {
|
|||||||
apple: { url: "/apple-touch-icon.png", sizes: "180x180" }
|
apple: { url: "/apple-touch-icon.png", sizes: "180x180" }
|
||||||
},
|
},
|
||||||
manifest: "/site.webmanifest",
|
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 }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="overflow-x-hidden">
|
<html lang="de" className="overflow-x-hidden">
|
||||||
<body className="overflow-x-hidden">
|
<body className="overflow-x-hidden">
|
||||||
<ContextProvider>
|
<ContextProvider>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -52,7 +51,6 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{/* next/script issue: https://github.com/vercel/next.js/issues/43402 */}
|
|
||||||
<script
|
<script
|
||||||
id="load-theme"
|
id="load-theme"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import CreateGame from "@/components/home/CreateGame";
|
import CreateGame from "@/components/home/CreateGame";
|
||||||
import JoinGame from "@/components/home/JoinGame";
|
import JoinGame from "@/components/home/JoinGame";
|
||||||
import PublicGames from "@/components/home/PublicGames/PublicGames";
|
import PublicGames from "@/components/home/PublicGames/PublicGames";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSession } from "@/context/session";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
return (
|
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 />
|
<PublicGames />
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|
||||||
|
{/* Against AI */}
|
||||||
<div className="flex flex-col items-center">
|
<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 />
|
<JoinGame />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divider divider-vertical">or</div>
|
<div className="divider divider-vertical">oder</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center">
|
<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 />
|
<CreateGame />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,17 @@
|
|||||||
import { IconBrandGithub } from "@tabler/icons-react";
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
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">
|
<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">
|
<div className="items-center">
|
||||||
<p>
|
<p>© {new Date().getFullYear()} Michess — Dein privates Schachbrett</p>
|
||||||
© 2023{" "}
|
|
||||||
<a
|
|
||||||
href="https://n9ze.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="link-hover"
|
|
||||||
>
|
|
||||||
nize
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="items-center">
|
<div className="items-center">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/dotnize/chessu"
|
href="https://git.mischlabs.de/MrDiderot/Michess"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn btn-ghost btn-sm gap-1 normal-case"
|
className="btn btn-ghost btn-sm gap-1 normal-case"
|
||||||
>
|
>
|
||||||
<IconBrandGithub className="inline-block" size={16} />
|
Gitea
|
||||||
GitHub
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -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 Link from "next/link";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
import ThemeToggle from "./ThemeToggle";
|
||||||
|
import { useSession } from "@/context/session";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const { user } = useSession();
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
chessu
|
Michess
|
||||||
</Link>
|
</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>
|
||||||
<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 />
|
<ThemeToggle />
|
||||||
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
|
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
|
||||||
<div className="w-10 rounded-full">
|
<div className="w-10 rounded-full">
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { User } from "@chessu/types";
|
import type { User } from "@chessu/types";
|
||||||
import { createContext, Dispatch, SetStateAction } from "react";
|
import { createContext, Dispatch, SetStateAction, useContext } from "react";
|
||||||
|
|
||||||
export const SessionContext = createContext<{
|
export const SessionContext = createContext<{
|
||||||
user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
|
user: User | null | undefined; // undefined = hasn't been checked yet, null = no user
|
||||||
setUser: Dispatch<SetStateAction<User | null>>;
|
setUser: Dispatch<SetStateAction<User | null>>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
export const useSession = () => {
|
||||||
|
const ctx = useContext(SessionContext);
|
||||||
|
if (!ctx) throw new Error("useSession must be used within ContextProvider");
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,37 +1,64 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
chessu:
|
michess:
|
||||||
image: chessu:latest # Replace with the correct image if needed
|
build: .
|
||||||
container_name: chessu_container
|
image: michess:latest
|
||||||
|
container_name: michess
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: chessu
|
NODE_ENV: production
|
||||||
POSTGRES_USER: exampleuser
|
# PostgreSQL connection (uses 'db' service below)
|
||||||
POSTGRES_PASSWORD: examplepassword
|
PGHOST: db
|
||||||
DATABASE_URL: postgres://chessu_user:chessu_pass@db:5432/chessu
|
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:
|
ports:
|
||||||
- "3000:3000"
|
- "${MICHESS_PORT:-3000}:3000"
|
||||||
- "3001:3001"
|
- "${MICHESS_API_PORT:-3001}:3001"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- app_pnpm:/opt/michess/.pnpm-store
|
||||||
networks:
|
networks:
|
||||||
- chessu_network
|
- michess_net
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:16-alpine
|
||||||
container_name: postgres_container
|
container_name: michess_db
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: chessu
|
POSTGRES_DB: ${POSTGRES_DB:-michess}
|
||||||
POSTGRES_USER: exampleuser
|
POSTGRES_USER: ${POSTGRES_USER:-michess}
|
||||||
POSTGRES_PASSWORD: examplepassword
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- chessu_network
|
- michess_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-michess}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
app_pnpm:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
chessu_network:
|
michess_net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
27
scripts/update.sh
Executable file
27
scripts/update.sh
Executable 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}}')"
|
||||||
113
server/src/controllers/admin.controller.ts
Normal file
113
server/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import UserModel from "../db/models/user.model.js";
|
||||||
|
|
||||||
|
export const getStats = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const usersRes = await db.query(`SELECT COUNT(*) FROM "user"`);
|
||||||
|
const gamesRes = await db.query(`SELECT COUNT(*) FROM "game"`);
|
||||||
|
const activeUsersRes = await db.query(
|
||||||
|
`SELECT COUNT(*) FROM "user" WHERE created_at > NOW() - INTERVAL '7 days'`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
totalUsers: parseInt(usersRes.rows[0].count),
|
||||||
|
totalGames: parseInt(gamesRes.rows[0].count),
|
||||||
|
newUsersThisWeek: parseInt(activeUsersRes.rows[0].count)
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listUsers = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
const offset = parseInt(req.query.offset as string) || 0;
|
||||||
|
const search = req.query.search as string | undefined;
|
||||||
|
|
||||||
|
const users = await UserModel.getAllUsers(limit, offset, search);
|
||||||
|
const countRes = await db.query(
|
||||||
|
search
|
||||||
|
? `SELECT COUNT(*) FROM "user" WHERE name ILIKE $1 OR email ILIKE $1`
|
||||||
|
: `SELECT COUNT(*) FROM "user"`,
|
||||||
|
search ? [`%${search}%`] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
users,
|
||||||
|
total: parseInt(countRes.rows[0].count),
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { role, banned } = req.body;
|
||||||
|
|
||||||
|
// Prevent admin from banning themselves
|
||||||
|
if (req.session.user?.id === id && banned === true) {
|
||||||
|
res.status(400).json({ message: "Cannot ban yourself." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await UserModel.adminUpdate(id, { role, banned });
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
|
||||||
|
if (req.session.user?.id === id) {
|
||||||
|
res.status(400).json({ message: "Cannot delete your own account." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = await UserModel.remove(id);
|
||||||
|
if (!removed) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json(removed);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const triggerUpdate = async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const workdir = process.env.APP_DIR || "/opt/michess";
|
||||||
|
|
||||||
|
exec(`cd ${workdir} && git pull origin main 2>&1`, (err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ message: "Git pull failed", output: stderr || err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({
|
||||||
|
message: "Update successful. Restart the container to apply changes.",
|
||||||
|
output: stdout
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
192
server/src/controllers/friends.controller.ts
Normal file
192
server/src/controllers/friends.controller.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { db } from "../db/index.js";
|
||||||
|
import UserModel from "../db/models/user.model.js";
|
||||||
|
|
||||||
|
export const sendRequest = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const fromId = req.session.user?.id as number;
|
||||||
|
const { username } = req.body;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
res.status(400).json({ message: "Username required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await UserModel.findByNameEmail({ name: username, email: username });
|
||||||
|
if (!targets || !targets.length) {
|
||||||
|
res.status(404).json({ message: "User not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toId = targets[0].id as number;
|
||||||
|
|
||||||
|
if (toId === fromId) {
|
||||||
|
res.status(400).json({ message: "Cannot add yourself." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check already friends
|
||||||
|
const existing = await db.query(
|
||||||
|
`SELECT id FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||||
|
[fromId, toId]
|
||||||
|
);
|
||||||
|
if (existing.rowCount) {
|
||||||
|
res.status(409).json({ message: "Already friends." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing request
|
||||||
|
const existingReq = await db.query(
|
||||||
|
`SELECT id, status FROM "friend_request" WHERE (from_id=$1 AND to_id=$2) OR (from_id=$2 AND to_id=$1)`,
|
||||||
|
[fromId, toId]
|
||||||
|
);
|
||||||
|
if (existingReq.rowCount) {
|
||||||
|
res.status(409).json({ message: "Request already exists." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO "friend_request"(from_id, to_id) VALUES($1, $2) RETURNING id`,
|
||||||
|
[fromId, toId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ id: result.rows[0].id, toName: targets[0].name });
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRequests = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
|
||||||
|
const incoming = await db.query(
|
||||||
|
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||||
|
u.name as from_name
|
||||||
|
FROM "friend_request" fr
|
||||||
|
JOIN "user" u ON u.id = fr.from_id
|
||||||
|
WHERE fr.to_id=$1 AND fr.status='pending'
|
||||||
|
ORDER BY fr.created_at DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const outgoing = await db.query(
|
||||||
|
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||||
|
u.name as to_name
|
||||||
|
FROM "friend_request" fr
|
||||||
|
JOIN "user" u ON u.id = fr.to_id
|
||||||
|
WHERE fr.from_id=$1 AND fr.status='pending'
|
||||||
|
ORDER BY fr.created_at DESC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
incoming: incoming.rows,
|
||||||
|
outgoing: outgoing.rows
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const respondToRequest = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
const requestId = parseInt(req.params.id);
|
||||||
|
const { action } = req.body; // "accept" | "reject"
|
||||||
|
|
||||||
|
if (action !== "accept" && action !== "reject") {
|
||||||
|
res.status(400).json({ message: "Invalid action." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqRow = await db.query(
|
||||||
|
`SELECT * FROM "friend_request" WHERE id=$1 AND to_id=$2 AND status='pending'`,
|
||||||
|
[requestId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reqRow.rowCount) {
|
||||||
|
res.status(404).json({ message: "Request not found." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from_id, to_id } = reqRow.rows[0];
|
||||||
|
|
||||||
|
await db.query(`UPDATE "friend_request" SET status=$1 WHERE id=$2`, [
|
||||||
|
action === "accept" ? "accepted" : "rejected",
|
||||||
|
requestId
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (action === "accept") {
|
||||||
|
const [a, b] = from_id < to_id ? [from_id, to_id] : [to_id, from_id];
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO "friendship"(user_id_1, user_id_2) VALUES($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
[a, b]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ message: action === "accept" ? "Friend added!" : "Request rejected." });
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFriends = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`SELECT
|
||||||
|
f.id,
|
||||||
|
CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END as friend_id,
|
||||||
|
u.name as friend_name,
|
||||||
|
u.wins, u.losses, u.draws,
|
||||||
|
f.created_at
|
||||||
|
FROM "friendship" f
|
||||||
|
JOIN "user" u ON u.id = CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END
|
||||||
|
WHERE f.user_id_1=$1 OR f.user_id_2=$1
|
||||||
|
ORDER BY u.name ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeFriend = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.session.user?.id as number;
|
||||||
|
const friendId = parseInt(req.params.id);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`DELETE FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||||
|
[userId, friendId]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).json({ message: "Friend removed." });
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchUsers = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const q = req.query.q as string;
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
res.status(400).json({ message: "Query too short." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await UserModel.searchByName(q, 10);
|
||||||
|
res.status(200).json(users);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
};
|
||||||
127
server/src/controllers/stockfish.controller.ts
Normal file
127
server/src/controllers/stockfish.controller.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
|
||||||
|
|
||||||
|
export interface StockfishLevel {
|
||||||
|
level: number;
|
||||||
|
name: string;
|
||||||
|
skillLevel: number;
|
||||||
|
depth: number;
|
||||||
|
moveTime: number; // ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_LEVELS: StockfishLevel[] = [
|
||||||
|
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 },
|
||||||
|
{ level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 },
|
||||||
|
{ level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 },
|
||||||
|
{ level: 4, name: "Fortgeschritten",skillLevel: 14, depth: 10, moveTime: 1000 },
|
||||||
|
{ level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 },
|
||||||
|
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export class StockfishEngine {
|
||||||
|
private process: ChildProcessWithoutNullStreams | null = null;
|
||||||
|
private buffer = "";
|
||||||
|
private resolvers: Map<string, (move: string) => void> = new Map();
|
||||||
|
private ready = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private start() {
|
||||||
|
// Try system stockfish first, then fallback to npm stockfish
|
||||||
|
const binary = process.env.STOCKFISH_PATH || "stockfish";
|
||||||
|
try {
|
||||||
|
this.process = spawn(binary, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
} catch {
|
||||||
|
console.error("Failed to start stockfish binary, trying node-stockfish fallback");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.process.stdout.on("data", (data: Buffer) => {
|
||||||
|
this.buffer += data.toString();
|
||||||
|
const lines = this.buffer.split("\n");
|
||||||
|
this.buffer = lines.pop() || "";
|
||||||
|
for (const line of lines) {
|
||||||
|
this.handleLine(line.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr.on("data", (data: Buffer) => {
|
||||||
|
console.error("Stockfish stderr:", data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("exit", (code) => {
|
||||||
|
console.log("Stockfish exited with code", code);
|
||||||
|
this.ready = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.send("uci");
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(cmd: string) {
|
||||||
|
if (this.process?.stdin.writable) {
|
||||||
|
this.process.stdin.write(cmd + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLine(line: string) {
|
||||||
|
if (line === "uciok") {
|
||||||
|
this.ready = true;
|
||||||
|
this.send("isready");
|
||||||
|
}
|
||||||
|
if (line === "readyok") {
|
||||||
|
// engine ready
|
||||||
|
}
|
||||||
|
if (line.startsWith("bestmove ")) {
|
||||||
|
const parts = line.split(" ");
|
||||||
|
const move = parts[1];
|
||||||
|
// resolve any pending resolver
|
||||||
|
const [key] = this.resolvers.entries().next().value ?? [];
|
||||||
|
if (key) {
|
||||||
|
this.resolvers.get(key)?.(move === "(none)" ? "" : move);
|
||||||
|
this.resolvers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBestMove(fen: string, level: StockfishLevel): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!this.process || !this.ready) {
|
||||||
|
resolve("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `${Date.now()}-${Math.random()}`;
|
||||||
|
this.resolvers.set(id, resolve);
|
||||||
|
|
||||||
|
this.send("ucinewgame");
|
||||||
|
this.send(`setoption name Skill Level value ${level.skillLevel}`);
|
||||||
|
this.send(`position fen ${fen}`);
|
||||||
|
this.send(`go depth ${level.depth} movetime ${level.moveTime}`);
|
||||||
|
|
||||||
|
// Timeout safety
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.resolvers.has(id)) {
|
||||||
|
this.resolvers.delete(id);
|
||||||
|
resolve("");
|
||||||
|
}
|
||||||
|
}, level.moveTime + 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.send("quit");
|
||||||
|
this.process?.kill();
|
||||||
|
this.process = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton engine instance
|
||||||
|
let engineInstance: StockfishEngine | null = null;
|
||||||
|
|
||||||
|
export const getEngine = (): StockfishEngine => {
|
||||||
|
if (!engineInstance) {
|
||||||
|
engineInstance = new StockfishEngine();
|
||||||
|
}
|
||||||
|
return engineInstance;
|
||||||
|
};
|
||||||
@@ -6,11 +6,13 @@ export const INIT_TABLES = /* sql */ `
|
|||||||
CREATE TABLE IF NOT EXISTS "user" (
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name VARCHAR(128) UNIQUE NOT NULL,
|
name VARCHAR(128) UNIQUE NOT NULL,
|
||||||
email VARCHAR(128),
|
email VARCHAR(128) UNIQUE,
|
||||||
password TEXT,
|
password TEXT,
|
||||||
|
role VARCHAR(16) DEFAULT 'user',
|
||||||
wins INTEGER DEFAULT 0,
|
wins INTEGER DEFAULT 0,
|
||||||
losses INTEGER DEFAULT 0,
|
losses INTEGER DEFAULT 0,
|
||||||
draws INTEGER DEFAULT 0,
|
draws INTEGER DEFAULT 0,
|
||||||
|
banned BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS "game" (
|
CREATE TABLE IF NOT EXISTS "game" (
|
||||||
@@ -22,7 +24,28 @@ export const INIT_TABLES = /* sql */ `
|
|||||||
white_name VARCHAR(32),
|
white_name VARCHAR(32),
|
||||||
black_id INT REFERENCES "user",
|
black_id INT REFERENCES "user",
|
||||||
black_name VARCHAR(32),
|
black_name VARCHAR(32),
|
||||||
|
vs_ai BOOLEAN DEFAULT FALSE,
|
||||||
|
ai_level INTEGER,
|
||||||
started_at TIMESTAMP NOT NULL,
|
started_at TIMESTAMP NOT NULL,
|
||||||
ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "friend_request" (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
from_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
to_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
status VARCHAR(16) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(from_id, to_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "friendship" (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id_1 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
user_id_2 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id_1, user_id_2)
|
||||||
|
);
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role VARCHAR(16) DEFAULT 'user';
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS banned BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS vs_ai BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS ai_level INTEGER;
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const create = async (user: User, password: string) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
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]
|
[user.name, user.email || null, password]
|
||||||
);
|
);
|
||||||
return res.rows[0] as User;
|
return res.rows[0] as User;
|
||||||
@@ -24,11 +24,11 @@ export const findById = async (id: number) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
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]
|
[id]
|
||||||
);
|
);
|
||||||
if (res.rowCount) {
|
if (res.rowCount) {
|
||||||
return res.rows[0] as User;
|
return res.rows[0] as User & { banned?: boolean };
|
||||||
} else return null;
|
} else return null;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
@@ -37,14 +37,13 @@ export const findById = async (id: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
|
export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
|
||||||
// if user is not specified, get all users
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
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]
|
[limit ?? 10]
|
||||||
);
|
);
|
||||||
return res.rows as (User & { password?: string })[];
|
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
return null;
|
return null;
|
||||||
@@ -53,12 +52,25 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await db.query(
|
const res = await db.query(
|
||||||
`SELECT id, name, email, wins, losses, draws${
|
`SELECT id, name, email, wins, losses, draws, role, banned${
|
||||||
includePassword ? `, password` : ""
|
includePassword ? `, password` : ""
|
||||||
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
||||||
[user.name, user.email, limit ?? 1]
|
[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) {
|
} catch (err: unknown) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
return null;
|
return null;
|
||||||
@@ -71,11 +83,11 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws`;
|
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, role, banned`;
|
||||||
let values = [updatedUser.name, updatedUser.email, id];
|
let values: (string | number | undefined)[] = [updatedUser.name, updatedUser.email, id];
|
||||||
|
|
||||||
if (updatedUser.password) {
|
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];
|
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
|
||||||
}
|
}
|
||||||
const res = await db.query(query, values);
|
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) => {
|
export const remove = async (id: number) => {
|
||||||
if (id === 0) {
|
if (id === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -106,7 +170,10 @@ const UserModel = {
|
|||||||
create,
|
create,
|
||||||
findById,
|
findById,
|
||||||
findByNameEmail,
|
findByNameEmail,
|
||||||
|
searchByName,
|
||||||
update,
|
update,
|
||||||
|
adminUpdate,
|
||||||
|
getAllUsers,
|
||||||
remove
|
remove
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
21
server/src/middleware/auth.ts
Normal file
21
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||||
|
res.status(401).json({ message: "Not authenticated." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||||
|
res.status(401).json({ message: "Not authenticated." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.session.user.role !== "admin") {
|
||||||
|
res.status(403).json({ message: "Admin access required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
@@ -27,7 +27,7 @@ const sessionMiddleware = session({
|
|||||||
secret: process.env.SESSION_SECRET || "make sure to change this!",
|
secret: process.env.SESSION_SECRET || "make sure to change this!",
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
name: "chessu",
|
name: "michess",
|
||||||
proxy: true,
|
proxy: true,
|
||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||||
|
|||||||
15
server/src/routes/admin.route.ts
Normal file
15
server/src/routes/admin.route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import express from "express";
|
||||||
|
import * as admin from "../controllers/admin.controller.js";
|
||||||
|
import { requireAdmin } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
router.get("/stats", admin.getStats);
|
||||||
|
router.get("/users", admin.listUsers);
|
||||||
|
router.patch("/users/:id", admin.updateUser);
|
||||||
|
router.delete("/users/:id", admin.deleteUser);
|
||||||
|
router.post("/update", admin.triggerUpdate);
|
||||||
|
|
||||||
|
export default router;
|
||||||
31
server/src/routes/ai.route.ts
Normal file
31
server/src/routes/ai.route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import express from "express";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post("/move", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { fen, level } = req.body;
|
||||||
|
|
||||||
|
if (!fen || typeof fen !== "string") {
|
||||||
|
res.status(400).json({ message: "FEN required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiLevel = AI_LEVELS.find((l) => l.level === (level || 3)) || AI_LEVELS[2];
|
||||||
|
const engine = getEngine();
|
||||||
|
const move = await engine.getBestMove(fen, aiLevel);
|
||||||
|
|
||||||
|
res.status(200).json({ move: move || null });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/levels", (_req: Request, res: Response) => {
|
||||||
|
res.status(200).json(AI_LEVELS.map(({ level, name }) => ({ level, name })));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
15
server/src/routes/friends.route.ts
Normal file
15
server/src/routes/friends.route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import express from "express";
|
||||||
|
import * as friends from "../controllers/friends.controller.js";
|
||||||
|
import { requireAuth } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
router.get("/", friends.getFriends);
|
||||||
|
router.post("/request", friends.sendRequest);
|
||||||
|
router.get("/requests", friends.getRequests);
|
||||||
|
router.patch("/requests/:id", friends.respondToRequest);
|
||||||
|
router.delete("/:id", friends.removeFriend);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
|
import admin from "./admin.route.js";
|
||||||
|
import ai from "./ai.route.js";
|
||||||
import auth from "./auth.route.js";
|
import auth from "./auth.route.js";
|
||||||
|
import friends from "./friends.route.js";
|
||||||
import games from "./games.route.js";
|
import games from "./games.route.js";
|
||||||
import users from "./users.route.js";
|
import users from "./users.route.js";
|
||||||
|
|
||||||
@@ -9,5 +12,8 @@ const router = Router();
|
|||||||
router.use("/games", games);
|
router.use("/games", games);
|
||||||
router.use("/auth", auth);
|
router.use("/auth", auth);
|
||||||
router.use("/users", users);
|
router.use("/users", users);
|
||||||
|
router.use("/admin", admin);
|
||||||
|
router.use("/friends", friends);
|
||||||
|
router.use("/ai", ai);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import * as controller from "../controllers/users.controller.js";
|
import * as controller from "../controllers/users.controller.js";
|
||||||
|
import { searchUsers } from "../controllers/friends.controller.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
router.get("/search", searchUsers);
|
||||||
router.route("/:name").get(controller.getUserProfile);
|
router.route("/:name").get(controller.getUserProfile);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -20,11 +20,25 @@ const server = createServer(app);
|
|||||||
|
|
||||||
// database
|
// database
|
||||||
await db.connect();
|
await db.connect();
|
||||||
db.query(INIT_TABLES, (err) => {
|
db.query(INIT_TABLES, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} else {
|
} 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.use(express.json());
|
||||||
app.set("trust proxy", 1);
|
app.set("trust proxy", 1);
|
||||||
app.use(session);
|
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);
|
app.use("/v1", routes);
|
||||||
|
|
||||||
// socket.io
|
// socket.io
|
||||||
@@ -41,8 +69,8 @@ io.use((socket, next) => {
|
|||||||
session(socket.request as Request, {} as Response, next as NextFunction);
|
session(socket.request as Request, {} as Response, next as NextFunction);
|
||||||
});
|
});
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const session = socket.request.session;
|
const sess = socket.request.session;
|
||||||
if (session && session.user) {
|
if (sess && sess.user) {
|
||||||
next();
|
next();
|
||||||
} else {
|
} else {
|
||||||
console.log("io.use: no session");
|
console.log("io.use: no session");
|
||||||
@@ -53,5 +81,5 @@ initSocket();
|
|||||||
|
|
||||||
const port = process.env.PORT || 3001;
|
const port = process.env.PORT || 3001;
|
||||||
server.listen(port, () => {
|
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
24
types/index.d.ts
vendored
@@ -12,6 +12,8 @@ export interface Game {
|
|||||||
observers?: User[];
|
observers?: User[];
|
||||||
startedAt?: number;
|
startedAt?: number;
|
||||||
endedAt?: number;
|
endedAt?: number;
|
||||||
|
vsAi?: boolean;
|
||||||
|
aiLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@@ -21,8 +23,30 @@ export interface User {
|
|||||||
wins?: number;
|
wins?: number;
|
||||||
losses?: number;
|
losses?: number;
|
||||||
draws?: number;
|
draws?: number;
|
||||||
|
role?: "user" | "admin";
|
||||||
|
|
||||||
// mainly for players, not spectators
|
// mainly for players, not spectators
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
disconnectedOn?: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user