Compare commits
10 Commits
f909419f8e
...
7dac042d73
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dac042d73 | ||
|
|
29968f023d | ||
|
|
8fdbc660a5 | ||
|
|
2ac873c5b1 | ||
|
|
a13ff39fd5 | ||
|
|
b55c382983 | ||
|
|
bf1b2159a7 | ||
|
|
d5008a88fa | ||
|
|
72c14280f0 | ||
|
|
394644bc81 |
8
.dockerignore
Normal file
8
.dockerignore
Normal 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
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
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: [dotnize]
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ yarn.lock
|
||||
|
||||
node_modules
|
||||
.pnp
|
||||
.pnpm-store
|
||||
.pnp.js
|
||||
|
||||
.next
|
||||
|
||||
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
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal 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
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
|
||||
40
README.md
40
README.md
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
© 2023{" "}
|
||||
<a
|
||||
href="https://n9ze.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="link-hover"
|
||||
>
|
||||
nize
|
||||
</a>
|
||||
</p>
|
||||
<p>© {new Date().getFullYear()} Michess — 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -324,8 +324,8 @@ export default function ArchivedGame({ game }: { game: Game }) {
|
||||
? "The game ended in a draw due to abandonment."
|
||||
: `The game was won by ${game.winner} due to abandonment.`
|
||||
: game.winner === "draw"
|
||||
? "The game ended in a draw."
|
||||
: `The game was won by checkmate (${game.winner}).`}
|
||||
? "The game ended in a draw."
|
||||
: `The game was won by checkmate (${game.winner}).`}
|
||||
|
||||
<div className="mt-2 flex items-center justify-end">
|
||||
<button
|
||||
|
||||
@@ -657,8 +657,8 @@ export default function GamePage({ initialLobby }: { initialLobby: Game }) {
|
||||
? `The game ended in a draw due to abandonment.`
|
||||
: `The game was won by ${lobby.winner} due to abandonment.`
|
||||
: lobby.winner === "draw"
|
||||
? "The game ended in a draw."
|
||||
: `The game was won by checkmate (${lobby.winner}).`}{" "}
|
||||
? "The game ended in a draw."
|
||||
: `The game was won by checkmate (${lobby.winner}).`}{" "}
|
||||
<br />
|
||||
You can review the archived game at{" "}
|
||||
<a
|
||||
|
||||
@@ -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
64
docker-compose.yml
Normal 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
|
||||
14
package.json
14
package.json
@@ -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
6351
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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}}')"
|
||||
@@ -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",
|
||||
|
||||
113
server/src/controllers/admin.controller.ts
Normal file
113
server/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { exec } from "child_process";
|
||||
import type { Request, Response } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import UserModel from "../db/models/user.model.js";
|
||||
|
||||
export const getStats = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const usersRes = await db.query(`SELECT COUNT(*) FROM "user"`);
|
||||
const gamesRes = await db.query(`SELECT COUNT(*) FROM "game"`);
|
||||
const activeUsersRes = await db.query(
|
||||
`SELECT COUNT(*) FROM "user" WHERE created_at > NOW() - INTERVAL '7 days'`
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
totalUsers: parseInt(usersRes.rows[0].count),
|
||||
totalGames: parseInt(gamesRes.rows[0].count),
|
||||
newUsersThisWeek: parseInt(activeUsersRes.rows[0].count)
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const listUsers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
const users = await UserModel.getAllUsers(limit, offset, search);
|
||||
const countRes = await db.query(
|
||||
search
|
||||
? `SELECT COUNT(*) FROM "user" WHERE name ILIKE $1 OR email ILIKE $1`
|
||||
: `SELECT COUNT(*) FROM "user"`,
|
||||
search ? [`%${search}%`] : []
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
users,
|
||||
total: parseInt(countRes.rows[0].count),
|
||||
limit,
|
||||
offset
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUser = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { role, banned } = req.body;
|
||||
|
||||
// Prevent admin from banning themselves
|
||||
if (req.session.user?.id === id && banned === true) {
|
||||
res.status(400).json({ message: "Cannot ban yourself." });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await UserModel.adminUpdate(id, { role, banned });
|
||||
if (!updated) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.status(200).json(updated);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteUser = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
if (req.session.user?.id === id) {
|
||||
res.status(400).json({ message: "Cannot delete your own account." });
|
||||
return;
|
||||
}
|
||||
|
||||
const removed = await UserModel.remove(id);
|
||||
if (!removed) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.status(200).json(removed);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const triggerUpdate = async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const workdir = process.env.APP_DIR || "/opt/michess";
|
||||
|
||||
exec(`cd ${workdir} && git pull origin main 2>&1`, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
res.status(500).json({ message: "Git pull failed", output: stderr || err.message });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({
|
||||
message: "Update successful. Restart the container to apply changes.",
|
||||
output: stdout
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
192
server/src/controllers/friends.controller.ts
Normal file
192
server/src/controllers/friends.controller.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import UserModel from "../db/models/user.model.js";
|
||||
|
||||
export const sendRequest = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const fromId = req.session.user?.id as number;
|
||||
const { username } = req.body;
|
||||
|
||||
if (!username) {
|
||||
res.status(400).json({ message: "Username required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = await UserModel.findByNameEmail({ name: username, email: username });
|
||||
if (!targets || !targets.length) {
|
||||
res.status(404).json({ message: "User not found." });
|
||||
return;
|
||||
}
|
||||
const toId = targets[0].id as number;
|
||||
|
||||
if (toId === fromId) {
|
||||
res.status(400).json({ message: "Cannot add yourself." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check already friends
|
||||
const existing = await db.query(
|
||||
`SELECT id FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||
[fromId, toId]
|
||||
);
|
||||
if (existing.rowCount) {
|
||||
res.status(409).json({ message: "Already friends." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check existing request
|
||||
const existingReq = await db.query(
|
||||
`SELECT id, status FROM "friend_request" WHERE (from_id=$1 AND to_id=$2) OR (from_id=$2 AND to_id=$1)`,
|
||||
[fromId, toId]
|
||||
);
|
||||
if (existingReq.rowCount) {
|
||||
res.status(409).json({ message: "Request already exists." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`INSERT INTO "friend_request"(from_id, to_id) VALUES($1, $2) RETURNING id`,
|
||||
[fromId, toId]
|
||||
);
|
||||
|
||||
res.status(201).json({ id: result.rows[0].id, toName: targets[0].name });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const getRequests = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
|
||||
const incoming = await db.query(
|
||||
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||
u.name as from_name
|
||||
FROM "friend_request" fr
|
||||
JOIN "user" u ON u.id = fr.from_id
|
||||
WHERE fr.to_id=$1 AND fr.status='pending'
|
||||
ORDER BY fr.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const outgoing = await db.query(
|
||||
`SELECT fr.id, fr.from_id, fr.to_id, fr.status, fr.created_at,
|
||||
u.name as to_name
|
||||
FROM "friend_request" fr
|
||||
JOIN "user" u ON u.id = fr.to_id
|
||||
WHERE fr.from_id=$1 AND fr.status='pending'
|
||||
ORDER BY fr.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
incoming: incoming.rows,
|
||||
outgoing: outgoing.rows
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const respondToRequest = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
const requestId = parseInt(req.params.id);
|
||||
const { action } = req.body; // "accept" | "reject"
|
||||
|
||||
if (action !== "accept" && action !== "reject") {
|
||||
res.status(400).json({ message: "Invalid action." });
|
||||
return;
|
||||
}
|
||||
|
||||
const reqRow = await db.query(
|
||||
`SELECT * FROM "friend_request" WHERE id=$1 AND to_id=$2 AND status='pending'`,
|
||||
[requestId, userId]
|
||||
);
|
||||
|
||||
if (!reqRow.rowCount) {
|
||||
res.status(404).json({ message: "Request not found." });
|
||||
return;
|
||||
}
|
||||
|
||||
const { from_id, to_id } = reqRow.rows[0];
|
||||
|
||||
await db.query(`UPDATE "friend_request" SET status=$1 WHERE id=$2`, [
|
||||
action === "accept" ? "accepted" : "rejected",
|
||||
requestId
|
||||
]);
|
||||
|
||||
if (action === "accept") {
|
||||
const [a, b] = from_id < to_id ? [from_id, to_id] : [to_id, from_id];
|
||||
await db.query(
|
||||
`INSERT INTO "friendship"(user_id_1, user_id_2) VALUES($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[a, b]
|
||||
);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: action === "accept" ? "Friend added!" : "Request rejected." });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const getFriends = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
|
||||
const result = await db.query(
|
||||
`SELECT
|
||||
f.id,
|
||||
CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END as friend_id,
|
||||
u.name as friend_name,
|
||||
u.wins, u.losses, u.draws,
|
||||
f.created_at
|
||||
FROM "friendship" f
|
||||
JOIN "user" u ON u.id = CASE WHEN f.user_id_1=$1 THEN f.user_id_2 ELSE f.user_id_1 END
|
||||
WHERE f.user_id_1=$1 OR f.user_id_2=$1
|
||||
ORDER BY u.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.status(200).json(result.rows);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFriend = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.session.user?.id as number;
|
||||
const friendId = parseInt(req.params.id);
|
||||
|
||||
await db.query(
|
||||
`DELETE FROM "friendship" WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)`,
|
||||
[userId, friendId]
|
||||
);
|
||||
|
||||
res.status(200).json({ message: "Friend removed." });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
|
||||
export const searchUsers = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const q = req.query.q as string;
|
||||
if (!q || q.length < 2) {
|
||||
res.status(400).json({ message: "Query too short." });
|
||||
return;
|
||||
}
|
||||
|
||||
const users = await UserModel.searchByName(q, 10);
|
||||
res.status(200).json(users);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
};
|
||||
127
server/src/controllers/stockfish.controller.ts
Normal file
127
server/src/controllers/stockfish.controller.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "child_process";
|
||||
|
||||
export interface StockfishLevel {
|
||||
level: number;
|
||||
name: string;
|
||||
skillLevel: number;
|
||||
depth: number;
|
||||
moveTime: number; // ms
|
||||
}
|
||||
|
||||
export const AI_LEVELS: StockfishLevel[] = [
|
||||
{ level: 1, name: "Anfänger", skillLevel: 0, depth: 1, moveTime: 100 },
|
||||
{ level: 2, name: "Leicht", skillLevel: 3, depth: 3, moveTime: 200 },
|
||||
{ level: 3, name: "Mittel", skillLevel: 8, depth: 5, moveTime: 500 },
|
||||
{ level: 4, name: "Fortgeschritten",skillLevel: 14, depth: 10, moveTime: 1000 },
|
||||
{ level: 5, name: "Experte", skillLevel: 18, depth: 15, moveTime: 2000 },
|
||||
{ level: 6, name: "Meister", skillLevel: 20, depth: 20, moveTime: 3000 },
|
||||
];
|
||||
|
||||
export class StockfishEngine {
|
||||
private process: ChildProcessWithoutNullStreams | null = null;
|
||||
private buffer = "";
|
||||
private resolvers: Map<string, (move: string) => void> = new Map();
|
||||
private ready = false;
|
||||
|
||||
constructor() {
|
||||
this.start();
|
||||
}
|
||||
|
||||
private start() {
|
||||
// Try system stockfish first, then fallback to npm stockfish
|
||||
const binary = process.env.STOCKFISH_PATH || "stockfish";
|
||||
try {
|
||||
this.process = spawn(binary, [], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
} catch {
|
||||
console.error("Failed to start stockfish binary, trying node-stockfish fallback");
|
||||
return;
|
||||
}
|
||||
|
||||
this.process.stdout.on("data", (data: Buffer) => {
|
||||
this.buffer += data.toString();
|
||||
const lines = this.buffer.split("\n");
|
||||
this.buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
this.handleLine(line.trim());
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr.on("data", (data: Buffer) => {
|
||||
console.error("Stockfish stderr:", data.toString());
|
||||
});
|
||||
|
||||
this.process.on("exit", (code) => {
|
||||
console.log("Stockfish exited with code", code);
|
||||
this.ready = false;
|
||||
});
|
||||
|
||||
this.send("uci");
|
||||
}
|
||||
|
||||
private send(cmd: string) {
|
||||
if (this.process?.stdin.writable) {
|
||||
this.process.stdin.write(cmd + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string) {
|
||||
if (line === "uciok") {
|
||||
this.ready = true;
|
||||
this.send("isready");
|
||||
}
|
||||
if (line === "readyok") {
|
||||
// engine ready
|
||||
}
|
||||
if (line.startsWith("bestmove ")) {
|
||||
const parts = line.split(" ");
|
||||
const move = parts[1];
|
||||
// resolve any pending resolver
|
||||
const [key] = this.resolvers.entries().next().value ?? [];
|
||||
if (key) {
|
||||
this.resolvers.get(key)?.(move === "(none)" ? "" : move);
|
||||
this.resolvers.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getBestMove(fen: string, level: StockfishLevel): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.process || !this.ready) {
|
||||
resolve("");
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random()}`;
|
||||
this.resolvers.set(id, resolve);
|
||||
|
||||
this.send("ucinewgame");
|
||||
this.send(`setoption name Skill Level value ${level.skillLevel}`);
|
||||
this.send(`position fen ${fen}`);
|
||||
this.send(`go depth ${level.depth} movetime ${level.moveTime}`);
|
||||
|
||||
// Timeout safety
|
||||
setTimeout(() => {
|
||||
if (this.resolvers.has(id)) {
|
||||
this.resolvers.delete(id);
|
||||
resolve("");
|
||||
}
|
||||
}, level.moveTime + 5000);
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.send("quit");
|
||||
this.process?.kill();
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton engine instance
|
||||
let engineInstance: StockfishEngine | null = null;
|
||||
|
||||
export const getEngine = (): StockfishEngine => {
|
||||
if (!engineInstance) {
|
||||
engineInstance = new StockfishEngine();
|
||||
}
|
||||
return engineInstance;
|
||||
};
|
||||
@@ -6,11 +6,13 @@ export const INIT_TABLES = /* sql */ `
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(128) UNIQUE NOT NULL,
|
||||
email VARCHAR(128),
|
||||
email VARCHAR(128) UNIQUE,
|
||||
password TEXT,
|
||||
role VARCHAR(16) DEFAULT 'user',
|
||||
wins INTEGER DEFAULT 0,
|
||||
losses INTEGER DEFAULT 0,
|
||||
draws INTEGER DEFAULT 0,
|
||||
banned BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "game" (
|
||||
@@ -22,7 +24,28 @@ export const INIT_TABLES = /* sql */ `
|
||||
white_name VARCHAR(32),
|
||||
black_id INT REFERENCES "user",
|
||||
black_name VARCHAR(32),
|
||||
vs_ai BOOLEAN DEFAULT FALSE,
|
||||
ai_level INTEGER,
|
||||
started_at TIMESTAMP NOT NULL,
|
||||
ended_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "friend_request" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
to_id INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
status VARCHAR(16) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(from_id, to_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "friendship" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id_1 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
user_id_2 INT REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id_1, user_id_2)
|
||||
);
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role VARCHAR(16) DEFAULT 'user';
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS banned BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS vs_ai BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE "game" ADD COLUMN IF NOT EXISTS ai_level INTEGER;
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,7 @@ export const create = async (user: User, password: string) => {
|
||||
|
||||
try {
|
||||
const res = await db.query(
|
||||
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws`,
|
||||
`INSERT INTO "user"(name, email, password) VALUES($1, $2, $3) RETURNING id, name, email, wins, losses, draws, role, banned`,
|
||||
[user.name, user.email || null, password]
|
||||
);
|
||||
return res.rows[0] as User;
|
||||
@@ -24,11 +24,11 @@ export const findById = async (id: number) => {
|
||||
}
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, email, wins, losses, draws FROM "user" WHERE id=$1`,
|
||||
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" WHERE id=$1`,
|
||||
[id]
|
||||
);
|
||||
if (res.rowCount) {
|
||||
return res.rows[0] as User;
|
||||
return res.rows[0] as User & { banned?: boolean };
|
||||
} else return null;
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
@@ -37,14 +37,13 @@ export const findById = async (id: number) => {
|
||||
};
|
||||
|
||||
export const findByNameEmail = async (user: User, includePassword = false, limit?: number) => {
|
||||
// if user is not specified, get all users
|
||||
if (!user) {
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, email, wins, losses, draws FROM "user" LIMIT $1`,
|
||||
`SELECT id, name, email, wins, losses, draws, role, banned FROM "user" LIMIT $1`,
|
||||
[limit ?? 10]
|
||||
);
|
||||
return res.rows as (User & { password?: string })[];
|
||||
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
@@ -53,12 +52,25 @@ export const findByNameEmail = async (user: User, includePassword = false, limit
|
||||
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, email, wins, losses, draws${
|
||||
`SELECT id, name, email, wins, losses, draws, role, banned${
|
||||
includePassword ? `, password` : ""
|
||||
} FROM "user" WHERE name=$1 OR email=$2 LIMIT $3`,
|
||||
[user.name, user.email, limit ?? 1]
|
||||
);
|
||||
return res.rows as (User & { password?: string })[];
|
||||
return res.rows as (User & { password?: string; banned?: boolean })[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const searchByName = async (query: string, limit = 10) => {
|
||||
try {
|
||||
const res = await db.query(
|
||||
`SELECT id, name, wins, losses, draws FROM "user" WHERE name ILIKE $1 LIMIT $2`,
|
||||
[`%${query}%`, limit]
|
||||
);
|
||||
return res.rows as User[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
@@ -71,11 +83,11 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
||||
}
|
||||
|
||||
try {
|
||||
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws`;
|
||||
let values = [updatedUser.name, updatedUser.email, id];
|
||||
let query = `UPDATE "user" SET name=$1, email=$2 WHERE id=$3 RETURNING id, name, email, wins, losses, draws, role, banned`;
|
||||
let values: (string | number | undefined)[] = [updatedUser.name, updatedUser.email, id];
|
||||
|
||||
if (updatedUser.password) {
|
||||
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws`;
|
||||
query = `UPDATE "user" SET name=$1, email=$2, password=$3 WHERE id=$4 RETURNING id, name, email, wins, losses, draws, role, banned`;
|
||||
values = [updatedUser.name, updatedUser.email, updatedUser.password, id];
|
||||
}
|
||||
const res = await db.query(query, values);
|
||||
@@ -86,6 +98,58 @@ export const update = async (id: number, updatedUser: User & { password?: string
|
||||
}
|
||||
};
|
||||
|
||||
export const adminUpdate = async (id: number, fields: { role?: string; banned?: boolean }) => {
|
||||
try {
|
||||
const setClauses: string[] = [];
|
||||
const values: (string | boolean | number)[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (fields.role !== undefined) {
|
||||
setClauses.push(`role=$${paramIndex++}`);
|
||||
values.push(fields.role);
|
||||
}
|
||||
if (fields.banned !== undefined) {
|
||||
setClauses.push(`banned=$${paramIndex++}`);
|
||||
values.push(fields.banned);
|
||||
}
|
||||
|
||||
if (!setClauses.length) return null;
|
||||
|
||||
values.push(id);
|
||||
const res = await db.query(
|
||||
`UPDATE "user" SET ${setClauses.join(", ")} WHERE id=$${paramIndex} RETURNING id, name, email, role, banned`,
|
||||
values
|
||||
);
|
||||
return res.rows[0] as User & { banned?: boolean };
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllUsers = async (limit = 50, offset = 0, search?: string) => {
|
||||
try {
|
||||
let query = `SELECT id, name, email, wins, losses, draws, role, banned, created_at FROM "user"`;
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
if (search) {
|
||||
query += ` WHERE name ILIKE $1 OR email ILIKE $1`;
|
||||
values.push(`%${search}%`);
|
||||
query += ` ORDER BY created_at DESC LIMIT $${values.length + 1} OFFSET $${values.length + 2}`;
|
||||
values.push(limit, offset);
|
||||
} else {
|
||||
query += ` ORDER BY created_at DESC LIMIT $1 OFFSET $2`;
|
||||
values.push(limit, offset);
|
||||
}
|
||||
|
||||
const res = await db.query(query, values);
|
||||
return res.rows as (User & { banned?: boolean; created_at?: string })[];
|
||||
} catch (err: unknown) {
|
||||
console.log(err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (id: number) => {
|
||||
if (id === 0) {
|
||||
return null;
|
||||
@@ -106,7 +170,10 @@ const UserModel = {
|
||||
create,
|
||||
findById,
|
||||
findByNameEmail,
|
||||
searchByName,
|
||||
update,
|
||||
adminUpdate,
|
||||
getAllUsers,
|
||||
remove
|
||||
};
|
||||
|
||||
|
||||
21
server/src/middleware/auth.ts
Normal file
21
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||
res.status(401).json({ message: "Not authenticated." });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.session.user?.id || typeof req.session.user.id === "string") {
|
||||
res.status(401).json({ message: "Not authenticated." });
|
||||
return;
|
||||
}
|
||||
if (req.session.user.role !== "admin") {
|
||||
res.status(403).json({ message: "Admin access required." });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
@@ -27,7 +27,7 @@ const sessionMiddleware = session({
|
||||
secret: process.env.SESSION_SECRET || "make sure to change this!",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: "chessu",
|
||||
name: "michess",
|
||||
proxy: true,
|
||||
cookie: {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
|
||||
15
server/src/routes/admin.route.ts
Normal file
15
server/src/routes/admin.route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from "express";
|
||||
import * as admin from "../controllers/admin.controller.js";
|
||||
import { requireAdmin } from "../middleware/auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAdmin);
|
||||
|
||||
router.get("/stats", admin.getStats);
|
||||
router.get("/users", admin.listUsers);
|
||||
router.patch("/users/:id", admin.updateUser);
|
||||
router.delete("/users/:id", admin.deleteUser);
|
||||
router.post("/update", admin.triggerUpdate);
|
||||
|
||||
export default router;
|
||||
31
server/src/routes/ai.route.ts
Normal file
31
server/src/routes/ai.route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { AI_LEVELS, getEngine } from "../controllers/stockfish.controller.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/move", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { fen, level } = req.body;
|
||||
|
||||
if (!fen || typeof fen !== "string") {
|
||||
res.status(400).json({ message: "FEN required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const aiLevel = AI_LEVELS.find((l) => l.level === (level || 3)) || AI_LEVELS[2];
|
||||
const engine = getEngine();
|
||||
const move = await engine.getBestMove(fen, aiLevel);
|
||||
|
||||
res.status(200).json({ move: move || null });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/levels", (_req: Request, res: Response) => {
|
||||
res.status(200).json(AI_LEVELS.map(({ level, name }) => ({ level, name })));
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
server/src/routes/friends.route.ts
Normal file
15
server/src/routes/friends.route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import express from "express";
|
||||
import * as friends from "../controllers/friends.controller.js";
|
||||
import { requireAuth } from "../middleware/auth.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get("/", friends.getFriends);
|
||||
router.post("/request", friends.sendRequest);
|
||||
router.get("/requests", friends.getRequests);
|
||||
router.patch("/requests/:id", friends.respondToRequest);
|
||||
router.delete("/:id", friends.removeFriend);
|
||||
|
||||
export default router;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import admin from "./admin.route.js";
|
||||
import ai from "./ai.route.js";
|
||||
import auth from "./auth.route.js";
|
||||
import friends from "./friends.route.js";
|
||||
import games from "./games.route.js";
|
||||
import users from "./users.route.js";
|
||||
|
||||
@@ -9,5 +12,8 @@ const router = Router();
|
||||
router.use("/games", games);
|
||||
router.use("/auth", auth);
|
||||
router.use("/users", users);
|
||||
router.use("/admin", admin);
|
||||
router.use("/friends", friends);
|
||||
router.use("/ai", ai);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import * as controller from "../controllers/users.controller.js";
|
||||
import { searchUsers } from "../controllers/friends.controller.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/search", searchUsers);
|
||||
router.route("/:name").get(controller.getUserProfile);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -20,11 +20,25 @@ const server = createServer(app);
|
||||
|
||||
// database
|
||||
await db.connect();
|
||||
db.query(INIT_TABLES, (err) => {
|
||||
db.query(INIT_TABLES, async (err) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
} else {
|
||||
console.log("Tables initialized");
|
||||
console.log("Michess tables initialized");
|
||||
|
||||
// Set admin role for ADMIN_EMAIL if configured
|
||||
const adminEmail = process.env.ADMIN_EMAIL;
|
||||
if (adminEmail) {
|
||||
try {
|
||||
await db.query(
|
||||
`UPDATE "user" SET role='admin' WHERE email=$1 AND role='user'`,
|
||||
[adminEmail]
|
||||
);
|
||||
console.log(`Admin role ensured for: ${adminEmail}`);
|
||||
} catch (e) {
|
||||
console.error("Failed to set admin role:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,6 +47,20 @@ app.use(cors(corsConfig));
|
||||
app.use(express.json());
|
||||
app.set("trust proxy", 1);
|
||||
app.use(session);
|
||||
|
||||
// Ban check middleware
|
||||
app.use("/v1", (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.session.user?.id && typeof req.session.user.id === "number") {
|
||||
const user = req.session.user as { banned?: boolean };
|
||||
if (user.banned) {
|
||||
req.session.destroy(() => {});
|
||||
res.status(403).json({ message: "Your account has been banned." });
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
app.use("/v1", routes);
|
||||
|
||||
// socket.io
|
||||
@@ -41,8 +69,8 @@ io.use((socket, next) => {
|
||||
session(socket.request as Request, {} as Response, next as NextFunction);
|
||||
});
|
||||
io.use((socket, next) => {
|
||||
const session = socket.request.session;
|
||||
if (session && session.user) {
|
||||
const sess = socket.request.session;
|
||||
if (sess && sess.user) {
|
||||
next();
|
||||
} else {
|
||||
console.log("io.use: no session");
|
||||
@@ -53,5 +81,5 @@ initSocket();
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
server.listen(port, () => {
|
||||
console.log(`chessu api server listening on :${port}`);
|
||||
console.log(`Michess API server listening on :${port}`);
|
||||
});
|
||||
|
||||
24
types/index.d.ts
vendored
24
types/index.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user