From a5d116358e24f8e826f15128929123ee98049819 Mon Sep 17 00:00:00 2001 From: Francesco Lorenzo D'Amico Date: Mon, 10 Aug 2026 15:18:25 +0200 Subject: [PATCH] Initial commit --- .gitattributes | 12 ++ .idea/workspace.xml | 72 --------- Backend/Dockerfile | 17 +++ Backend/lib/simpleWebSocket.js | 40 ++++- Backend/lib/stores/jsonStore.js | 38 ++++- Backend/lib/stores/mongoStore.js | 67 +++++++-- Backend/routes/rooms.js | 8 + README.md | 49 ++++++ docker-compose.yml | 149 ++++++++++++------- frontend/Dockerfile | 26 +++- frontend/nginx.conf | 33 ++++- frontend/src/App.css | 59 ++++++++ frontend/src/App.jsx | 180 +++++++++++++++++++++-- frontend/src/locales/de/translation.json | 3 + testprotokoll.md | 112 ++++++++++++++ 15 files changed, 700 insertions(+), 165 deletions(-) create mode 100644 .gitattributes delete mode 100644 .idea/workspace.xml create mode 100644 README.md create mode 100644 testprotokoll.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..86bd753 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +* text=auto + +Dockerfile text eol=lf +docker-compose.yml text eol=lf + +*.sh text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.conf text eol=lf diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index 5adac47..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1751619297246 - - - - - - - - - - - - \ No newline at end of file diff --git a/Backend/Dockerfile b/Backend/Dockerfile index 0f7b4f9..6d1290e 100644 --- a/Backend/Dockerfile +++ b/Backend/Dockerfile @@ -1,15 +1,32 @@ +# Docker lädt ein fertiges Image mit Linux Alpine und Node.js Version 20. +# Alpine ist eine sehr kleine Linux-Distribution — kleineres Image, schnellere Downloads. FROM node:20-alpine +# Docker erstellt Verzeichnis /app falls noch nicht vorhanden. +# Alle folgenden Befehle laufen in diesem Verzeichnis. WORKDIR /app +# Zuerst nur package.json kopieren — noch nicht den ganzen Code. +# Warum: Docker cached jeden Schritt. Wenn sich nur der Code ändert aber nicht die +# Dependencies, muss npm ci nicht nochmal laufen — Build wird schneller (Layer Caching). COPY package*.json ./ + +# npm ci installiert exakt die Versionen aus package-lock.json — reproduzierbarer Build. +# --omit=dev: Keine Entwicklungs-Dependencies in Produktion — kleineres und sichereres Image. RUN npm ci --omit=dev +# Jetzt erst den restlichen Code kopieren — nach npm ci damit der Cache optimal genutzt wird. COPY . . +# NODE_ENV=production aktiviert Optimierungen in Express (z.B. bessere Performance, weniger Logs). +# PORT=4000 definiert auf welchem Port der Server lauscht. ENV NODE_ENV=production ENV PORT=4000 +# Dokumentiert welchen Port der Container verwendet — öffnet ihn aber nicht wirklich. +# Das passiert in docker-compose.yml. EXPOSE 4000 +# Startbefehl wenn der Container läuft. +# Array-Form statt String damit Node.js direkt als Prozess gestartet wird — kein Shell-Prozess dazwischen. CMD ["node", "index.js"] diff --git a/Backend/lib/simpleWebSocket.js b/Backend/lib/simpleWebSocket.js index 4a1f082..5448b33 100644 --- a/Backend/lib/simpleWebSocket.js +++ b/Backend/lib/simpleWebSocket.js @@ -9,6 +9,11 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) { }, }) + const typingByRoom = new Map() + + // io.use: Middleware die bei jeder eingehenden WebSocket-Verbindung läuft. + // Prüft ob room und userId im Handshake vorhanden sind und ob der User in der DB existiert. + // Wer vorher nicht POST /join aufgerufen hat, hat keine gültige userId und wird abgelehnt. io.use(async (socket, next) => { try { const auth = socket.handshake.auth || {} @@ -25,6 +30,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) { return next(new Error('User not authorized for this room')) } + // room und userId in socket.data speichern — so sind sie in registerConnection verfügbar. socket.data.room = room socket.data.userId = userId next() @@ -34,7 +40,7 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) { }) io.on('connection', (socket) => { - registerConnection(io, socket, database, onError).catch((error) => { + registerConnection(io, socket, database, onError, typingByRoom).catch((error) => { onError('Socket connection setup failed', error) socket.disconnect(true) }) @@ -43,8 +49,10 @@ function createRealtimeServer(server, { clientOrigin, database, onError }) { return io } -async function registerConnection(io, socket, database, onError) { +async function registerConnection(io, socket, database, onError, typingByRoom) { const { room, userId } = socket.data + + // socket.join: Fügt den Socket einem Room-Kanal hinzu — io.to(room).emit() erreicht nur diese Sockets. socket.join(room) const user = await database.setUserOnline(userId, room) @@ -53,10 +61,25 @@ async function registerConnection(io, socket, database, onError) { return } + socket.data.username = user.username + socket.emit('connection:ready', { userId, room }) - socket.emit('history:init', await database.getMessages(room)) + + // Teilnehmerliste an alle im Room senden — User sieht sofort wer online ist. await broadcastParticipants(io, database, room) + // typingByRoom: Map pro Room — speichert wer gerade tippt { userId → { userId, username } }. + socket.on('typing:start', () => { + if (!typingByRoom.has(room)) typingByRoom.set(room, new Map()) + typingByRoom.get(room).set(userId, { userId, username: socket.data.username }) + io.to(room).emit('typing:update', [...typingByRoom.get(room).values()]) + }) + + socket.on('typing:stop', () => { + typingByRoom.get(room)?.delete(userId) + io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])]) + }) + socket.on('message:send', async (payload) => { try { const text = @@ -65,11 +88,16 @@ async function registerConnection(io, socket, database, onError) { return } + // Nochmal prüfen ob der User noch im richtigen Room ist — könnte sich seit dem Verbinden geändert haben. const activeUser = await database.getUser(userId) if (!activeUser || activeUser.room !== room) { return } + // Tipp-Indikator sofort entfernen wenn eine Nachricht gesendet wird. + typingByRoom.get(room)?.delete(userId) + io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])]) + const message = await database.addMessage({ id: randomUUID(), room, @@ -77,6 +105,7 @@ async function registerConnection(io, socket, database, onError) { username: activeUser.username, text: text.trim(), }) + // Nachricht an alle im Room senden — inkl. Sender selbst. io.to(room).emit('message:new', message) } catch (error) { onError('Failed to handle socket message', error) @@ -84,6 +113,9 @@ async function registerConnection(io, socket, database, onError) { }) socket.on('disconnect', () => { + typingByRoom.get(room)?.delete(userId) + io.to(room).emit('typing:update', [...(typingByRoom.get(room)?.values() ?? [])]) + // User als offline markieren und Teilnehmerliste aktualisieren. database .setUserOffline(userId) .then(() => broadcastParticipants(io, database, room)) @@ -100,6 +132,8 @@ async function broadcastParticipants(io, database, room) { io.to(room).emit('participants:update', participants) } +// Liest einen Parameter zuerst aus der primären Quelle (auth), dann aus dem Fallback (query). +// Schützt gegen leere Strings — nur echte Werte werden akzeptiert. function readParam(primary, fallback) { if (typeof primary === 'string' && primary.trim()) return primary if (typeof fallback === 'string' && fallback.trim()) return fallback diff --git a/Backend/lib/stores/jsonStore.js b/Backend/lib/stores/jsonStore.js index f21cf83..2efa7f2 100644 --- a/Backend/lib/stores/jsonStore.js +++ b/Backend/lib/stores/jsonStore.js @@ -1,33 +1,44 @@ const fs = require('fs') const path = require('path') +// JsonStore: Dateibasierter Fallback-Store wenn kein MongoDB vorhanden ist. +// Alle Daten liegen als JSON-Datei auf der Festplatte — kein externer Datenbankserver nötig. +// Nachteil gegenüber MongoDB: Kein Concurrency-Schutz bei mehreren gleichzeitigen Schreiboperationen. class JsonStore { constructor(filePath) { this.filePath = filePath - this.state = { users: [], messages: [] } + this.state = { users: [], messages: [] } // In-Memory-State — alle Operationen laufen darauf. this._ensureStore() } + // Stellt sicher dass die JSON-Datei und das Verzeichnis existieren. + // Wenn die Datei beschädigt ist (kein gültiges JSON), wird sie neu erstellt. _ensureStore() { const dir = path.dirname(this.filePath) - fs.mkdirSync(dir, { recursive: true }) + fs.mkdirSync(dir, { recursive: true }) // Verzeichnis anlegen falls nicht vorhanden. if (!fs.existsSync(this.filePath)) { this._persist() return } try { const contents = fs.readFileSync(this.filePath, 'utf-8') - this.state = JSON.parse(contents) + this.state = JSON.parse(contents) // Datei in den In-Memory-State laden. } catch (error) { + // Datei vorhanden aber nicht lesbar oder kein gültiges JSON — neu erstellen. console.warn('JSON store damaged, recreating', error) this._persist() } } + // Schreibt den aktuellen In-Memory-State in die JSON-Datei. + // null, 2 formatiert das JSON mit Einrückung — besser lesbar beim Debugging. _persist() { fs.writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8') } + // Upsert: User aktualisieren wenn vorhanden, sonst neu anlegen. + // Spread-Operator {...this.state.users[index], ...} übernimmt alle bestehenden Felder + // und überschreibt nur die geänderten — joinedAt bleibt beim Update erhalten. async upsertUser({ id, username, room }) { const trimmed = username?.trim() if (!trimmed) { @@ -60,6 +71,7 @@ class JsonStore { return user } + // Object.assign mutiert das gefundene Objekt direkt im Array — kein Index-Lookup nötig. async setUserOnline(userId, room) { const user = this.state.users.find((entry) => entry.id === userId) if (!user) { @@ -74,6 +86,7 @@ class JsonStore { return user } + // lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war. async setUserOffline(userId) { const user = this.state.users.find((entry) => entry.id === userId) if (!user) { @@ -96,6 +109,8 @@ class JsonStore { return user } + // splice(index, 1) entfernt genau ein Element an der gefundenen Position. + // Destructuring [removed] holt das gelöschte Element aus dem zurückgegebenen Array. async removeUser(userId) { const index = this.state.users.findIndex((entry) => entry.id === userId) if (index === -1) return null @@ -108,6 +123,8 @@ class JsonStore { return this.state.users.find((entry) => entry.id === userId) || null } + // .map() gibt nur die öffentlichen Felder zurück — interne Felder werden nicht nach außen gegeben. + // Sortierung: Online-User zuerst, dann nach letzter Aktivität absteigend. async getParticipants(room) { return this.state.users .filter((entry) => entry.room === room) @@ -126,6 +143,7 @@ class JsonStore { }) } + // createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut. async addMessage({ id, room, userId, username, text }) { const message = { id, @@ -140,11 +158,25 @@ class JsonStore { return message } + // slice(-limit) gibt die letzten N Nachrichten zurück — neueste am Ende, älteste zuerst. async getMessages(room, limit = 100) { return this.state.messages .filter((message) => message.room === room) .slice(-limit) } + + // roomMap: Objekt das pro Room die Teilnehmerzahl zählt — schneller als mehrere filter()-Aufrufe. + // localeCompare: Alphabetische Sortierung mit Sprachunterstützung (Umlaute etc.). + async getRooms() { + const roomMap = {} + for (const user of this.state.users) { + if (!roomMap[user.room]) { + roomMap[user.room] = { name: user.room, participantCount: 0 } + } + if (user.isOnline) roomMap[user.room].participantCount++ + } + return Object.values(roomMap).sort((a, b) => a.name.localeCompare(b.name)) + } } module.exports = JsonStore diff --git a/Backend/lib/stores/mongoStore.js b/Backend/lib/stores/mongoStore.js index 8584df5..2c30977 100644 --- a/Backend/lib/stores/mongoStore.js +++ b/Backend/lib/stores/mongoStore.js @@ -1,6 +1,7 @@ const config = require('../../config/database') - +// Sicheres Laden des MongoDB-Treibers — wenn das Paket nicht installiert ist, +// wird der Fehler erst in createMongoStore() mit einer klaren Meldung geworfen. let MongoClient try { ;({ MongoClient } = require('mongodb')) @@ -8,12 +9,14 @@ try { MongoClient = null } - +// Collection-Namen als Konstanten — wenn sich ein Name ändert, muss er nur hier angepasst werden. const collectionNames = { users: 'users', messages: 'messages', } +// MongoDB speichert den Primärschlüssel intern als "_id". +// Diese Funktion übersetzt das in "id" damit der Rest der App nichts von MongoDB-Interna wissen muss. function normalizeUser(doc) { if (!doc) return null const { @@ -34,6 +37,8 @@ function normalizeUser(doc) { } } +// Gleiche Logik wie normalizeUser — username wird redundant in der Nachricht gespeichert +// damit beim Laden keine extra DB-Abfrage für den Usernamen nötig ist. function normalizeMessage(doc) { if (!doc) return null const { @@ -54,6 +59,8 @@ function normalizeMessage(doc) { } } +// Kompatibilitätsfunktion für ältere MongoDB-Treiber (< 4.0). +// findOneAndUpdate gibt dort das Ergebnis in einem "value"-Wrapper zurück — ab 4.0 direkt. function extractDocument(result) { if (!result) return null return Object.prototype.hasOwnProperty.call(result, 'value') @@ -61,8 +68,9 @@ function extractDocument(result) { : result } +// async damit der Start der App nicht blockiert während MongoDB-Verbindung und Indizes aufgebaut werden. async function createMongoStore() { - // Guard: fail fast with an actionable message if the driver is missing. + if (!MongoClient) { const message = 'MongoDB driver is not installed. Run `npm install mongodb` in Backend/.' @@ -73,18 +81,20 @@ async function createMongoStore() { const client = new MongoClient(config.uri, { ...config.options, - appName: 'chat-app', + appName: 'chat-app', // Wird in MongoDB Atlas angezeigt um zu sehen welche App verbunden ist. }) await client.connect() - const db = client.db(config.dbName) + const db = client.db(config.dbName) // Entspricht "USE chatapp" in SQL. const users = db.collection(collectionNames.users) const messages = db.collection(collectionNames.messages) + // Indizes werden einmalig beim Start angelegt — ohne Index durchsucht MongoDB jede Abfrage + // alle Dokumente (Full Collection Scan). Promise.all legt beide parallel an. await Promise.all([ - users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), - messages.createIndex({ room: 1, createdAt: 1 }), + users.createIndex({ room: 1, isOnline: -1, lastActiveAt: -1 }), // Für getParticipants() + messages.createIndex({ room: 1, createdAt: 1 }), // Für getMessages() ]) console.log( @@ -93,7 +103,12 @@ async function createMongoStore() { }/${config.dbName} (collections: ${Object.values(collectionNames).join(', ')})`, ) + // Öffentliches Interface des Stores — der Rest der App kennt keine MongoDB-Details (Repository-Pattern). return { + + // Upsert = Update + Insert in einem Schritt (atomare Operation). + // Wenn User existiert: Felder aktualisieren. Wenn nicht: neu anlegen. + // $setOnInsert stellt sicher dass joinedAt nur beim ersten Anlegen gesetzt wird, nicht bei Updates. async upsertUser({ id, username, room }) { const now = new Date() const result = await users.findOneAndUpdate( @@ -112,7 +127,7 @@ async function createMongoStore() { }, { upsert: true, - returnDocument: 'after', + returnDocument: 'after', // Aktualisiertes Dokument zurückgeben, nicht das alte. }, ) const document = extractDocument(result) @@ -120,11 +135,12 @@ async function createMongoStore() { return normalizeUser(document) } - + // Fallback: Manche MongoDB-Versionen geben bei einem Upsert-Insert kein Dokument zurück. const inserted = await users.findOne({ _id: id }) return normalizeUser(inserted) }, + // $set aktualisiert nur die angegebenen Felder — der Rest (z.B. username) bleibt unberührt. async setUserOnline(userId, room) { const result = await users.findOneAndUpdate( { _id: userId }, @@ -140,6 +156,7 @@ async function createMongoStore() { return normalizeUser(extractDocument(result)) }, + // lastActiveAt wird auch beim Offline-Setzen aktualisiert — zeigt wann der User zuletzt aktiv war. async setUserOffline(userId) { const result = await users.findOneAndUpdate( { _id: userId }, @@ -154,6 +171,7 @@ async function createMongoStore() { return normalizeUser(extractDocument(result)) }, + // trim() auf Datenbankebene — auch wenn die Frontend-Validierung fehlt, landen keine schmutzigen Daten in der DB. async updateUsername(userId, username) { const result = await users.findOneAndUpdate( { _id: userId }, @@ -168,6 +186,7 @@ async function createMongoStore() { return normalizeUser(extractDocument(result)) }, + // findOneAndDelete löscht und gibt das Dokument in einem Schritt zurück — kein extra findOne() nötig. async removeUser(userId) { const result = await users.findOneAndDelete({ _id: userId }) return normalizeUser(extractDocument(result)) @@ -178,14 +197,19 @@ async function createMongoStore() { return normalizeUser(doc) }, + // Online-User zuerst, dann nach letzter Aktivität sortiert. + // toArray() lädt alle Cursor-Ergebnisse in den Speicher — bei sehr großen Datenmengen wäre ein Cursor besser. async getParticipants(room) { const docs = await users .find({ room }) - .sort({ isOnline: -1, lastActiveAt: -1 }) + .sort({ isOnline: -1, lastActiveAt: -1 }) // -1 = absteigend, 1 = aufsteigend .toArray() return docs.map(normalizeUser) }, + // _id wird von außen als UUID übergeben statt von MongoDB generiert — + // so ist die ID bereits bekannt bevor die DB antwortet (z.B. für den WebSocket-Broadcast). + // createdAt wird serverseitig gesetzt — dem Client-Timestamp wird nicht vertraut. async addMessage({ id, room, userId, username, text }) { const message = { _id: id, @@ -196,9 +220,11 @@ async function createMongoStore() { createdAt: new Date(), } await messages.insertOne(message) + // Lokales Objekt zurückgeben statt nochmal aus der DB lesen — spart einen Query. return normalizeMessage(message) }, + // limit = 100 verhindert dass bei einem langen Chatverlauf alles auf einmal geladen wird. async getMessages(room, limit = 100) { const docs = await messages .find({ room }) @@ -208,6 +234,27 @@ async function createMongoStore() { return docs.map(normalizeMessage) }, + // Aggregation Pipeline: MongoDB berechnet das Ergebnis serverseitig — effizienter als + // alle Daten laden und in JavaScript auswerten. + // $group: Gruppiert User nach Room (wie GROUP BY in SQL). + // $cond: Zählt nur Online-User pro Room. + // $project: Blendet _id aus und benennt es in "name" um. + async getRooms() { + const result = await users.aggregate([ + { + $group: { + _id: '$room', + participantCount: { $sum: { $cond: ['$isOnline', 1, 0] } }, + }, + }, + { $project: { _id: 0, name: '$_id', participantCount: 1 } }, + { $sort: { name: 1 } }, + ]).toArray() + return result + }, + + // Verbindung sauber trennen beim Herunterfahren der App — ohne close() bleiben + // offene Verbindungen auf dem MongoDB-Server hängen. async disconnect() { await client.close() }, diff --git a/Backend/routes/rooms.js b/Backend/routes/rooms.js index ae02de1..1031f45 100644 --- a/Backend/routes/rooms.js +++ b/Backend/routes/rooms.js @@ -5,6 +5,14 @@ const asyncHandler = require('../utils/asyncHandler') function createRoomsRouter({ database, io }) { const router = express.Router() + router.get( + '/', + asyncHandler(async (_req, res) => { + const rooms = await database.getRooms() + res.json({ rooms }) + }), + ) + router.post( '/:room/join', asyncHandler(async (req, res) => { diff --git a/README.md b/README.md new file mode 100644 index 0000000..dff87fd --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +## Docker Setup + +Run the full stack with: + +```bash +docker compose up --build +``` + +Services: + +- Frontend: `http://localhost:5173` +- Backend: `http://localhost:4000` +- MongoDB: `mongodb://localhost:27017/chatapp` + +The Compose file starts all required services together: + +- `frontend` as a Vite container +- `backend` as a Node/Express container +- `mongo` with a persistent `mongo-data` volume + +Stop everything with: + +```bash +docker compose down +``` + +Remove containers and database volume with: + +```bash +docker compose down -v +``` + +## Local Development Without Docker + +1. Copy the backend environment file: + + ```bash + cp Backend/.env.example Backend/.env + ``` + +2. Start MongoDB locally or with Docker. + +3. Start the backend: + + ```bash + cd Backend && npm run dev + ``` + +The backend tries MongoDB at `mongodb://127.0.0.1:27017/chatapp` first and falls back to `Backend/data/db.json` only if MongoDB is unavailable. Use `DATA_STORE=mongo` to fail fast on MongoDB errors, or `DATA_STORE=json` to force the JSON store. diff --git a/docker-compose.yml b/docker-compose.yml index 4430181..de008da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,59 +1,98 @@ -services: - frontend: - image: francesco448/chat-app-frontend:latest - build: - context: ./frontend - args: - VITE_API_URL: "" - restart: unless-stopped - depends_on: - - backend - ports: - - "80:80" - networks: - - chat-network - - backend: - image: francesco448/chat-app-backend:latest - build: - context: ./Backend - restart: unless-stopped - depends_on: - mongo: - condition: service_healthy - environment: - - PORT=4000 - - CLIENT_ORIGIN=http://localhost - - DATA_STORE=mongo - - MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:-chatpass}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin - - MONGO_DB=${MONGO_DB:-chatapp} - networks: - - chat-network - - mongo: - image: mongo:7 - container_name: chat-app-mongo - restart: unless-stopped - environment: - - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin} - - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:-chatpass} - - MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp} - volumes: - - mongo-data:/data/db - ports: - - "27017:27017" - networks: - - chat-network - healthcheck: - test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] - interval: 10s - timeout: 5s - retries: 5 - networks: chat-network: driver: bridge -volumes: - mongo-data: - driver: local +services: + frontend: + # Fertiges Frontend-Image von Docker Hub. + image: francesco448/chat-app-frontend:latest + container_name: chat-app-frontend + restart: unless-stopped + + # Frontend startet erst, nachdem der Backend-Container gestartet wurde. + depends_on: + - backend + + # Nur das Frontend wird nach außen veröffentlicht. + # Standardmäßig TrueNAS-Port 9004 -> Nginx-Port 80. + # FRONTEND_PORT kann optional über .env geändert werden. + ports: + - "${FRONTEND_PORT:-9004}:80" + + networks: + - chat-network + + + backend: + image: francesco448/chat-app-backend:latest + container_name: chat-app-backend + restart: unless-stopped + + depends_on: + mongo: + condition: service_healthy + + environment: + - PORT=4000 + - NODE_ENV=production + - CLIENT_ORIGIN=${CLIENT_ORIGIN:?CLIENT_ORIGIN muss gesetzt werden} + - DATA_STORE=mongo + - MONGO_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss gesetzt werden}@mongo:27017/${MONGO_DB:-chatapp}?authSource=admin + - MONGO_DB=${MONGO_DB:-chatapp} + + networks: + - chat-network + + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://localhost:4000/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + + mongo: + # Feste Major-Version verwenden statt "latest". + image: mongo:7 + container_name: chat-app-mongo + restart: unless-stopped + + environment: + # Benutzer kann über .env überschrieben werden. + - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin} + + # Für Production kein unsicheres Default-Passwort wie "chatpass". + # Der Stack startet nicht, solange das Passwort in .env fehlt. + - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD muss in .env gesetzt werden} + + # Datenbankname kann über .env überschrieben werden. + - MONGO_INITDB_DATABASE=${MONGO_DB:-chatapp} + + # Persistenz direkt auf deinem TrueNAS-Dataset. + volumes: + - /mnt/PoolHDD/AppData/Chat-App/mongodb:/data/db + + # Kein Port 27017 nach außen. + # MongoDB ist ausschließlich im internen Docker-Netzwerk erreichbar. + networks: + - chat-network + + # Authentifizierter MongoDB-Healthcheck. + # $$ verhindert, dass Docker Compose die Variablen bereits auf dem Host ersetzt. + healthcheck: + test: + - CMD-SHELL + - >- + mongosh --quiet + --host 127.0.0.1 + --username "$$MONGO_INITDB_ROOT_USERNAME" + --password "$$MONGO_INITDB_ROOT_PASSWORD" + --authenticationDatabase admin + --eval "quit(db.adminCommand('ping').ok ? 0 : 2)" + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ab67f07..c7a70d9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,23 +1,47 @@ +# Multi-Stage Build: Zwei separate Phasen in einem Dockerfile. +# Vorteil: Der finale Container enthält nur das Minimum — kein Node.js, kein Source-Code, keine node_modules. +# Kleineres und sichereres Image als wenn alles in einem Stage wäre. + +# Stage 1: Builder — zuständig für das Bauen der React-App. +# "AS builder" gibt diesem Stage einen Namen damit Stage 2 darauf zugreifen kann. FROM node:20-alpine AS builder +# Arbeitsverzeichnis setzen — alle folgenden Befehle laufen hier. WORKDIR /app +# Zuerst nur package.json kopieren für optimales Layer Caching. +# npm ci läuft nur neu wenn sich die Dependencies ändern, nicht bei jedem Code-Change. COPY package*.json ./ RUN npm ci +# Code kopieren — nach npm ci damit der Cache optimal genutzt wird. COPY . . - +# VITE_API_URL als Build-Argument — wird von docker-compose.yml übergeben. +# ARG ist nur während des Builds verfügbar, ENV macht sie für Vite lesbar. +# Warum beide: Vite läuft als Node.js-Prozess und liest ENV-Variablen — nicht ARG direkt. +# Leerer Default "" bedeutet relative URLs werden verwendet — Nginx leitet intern weiter. ARG VITE_API_URL="" ENV VITE_API_URL=$VITE_API_URL +# Vite baut die React-App in den /app/dist Ordner — optimiertes, minifiziertes Bundle. RUN npm run build +# Stage 2: Nginx — zuständig für das Ausliefern der gebauten Dateien. +# Node.js aus Stage 1 wird komplett weggeworfen — nur der dist-Ordner wird übernommen. FROM nginx:alpine +# Nur den fertigen dist-Ordner aus Stage 1 kopieren — kein Source-Code, kein Node.js. +# --from=builder verweist auf Stage 1 per Name. COPY --from=builder /app/dist /usr/share/nginx/html + +# Eigene Nginx-Konfiguration kopieren — überschreibt die Standard-Konfiguration. +# Enthält Reverse Proxy Regeln für /rooms und /socket.io sowie SPA-Fallback. COPY nginx.conf /etc/nginx/conf.d/default.conf +# Port 80 dokumentieren — Standard HTTP-Port. EXPOSE 80 +# Nginx im Vordergrund starten — ohne "daemon off" würde Nginx in den Hintergrund gehen +# und Docker würde denken der Container ist fertig und ihn stoppen. CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index c39a185..cb4b7f9 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,37 +1,60 @@ server { + # Nginx lauscht auf Port 80 (Standard HTTP-Port). + # Der Browser spricht Nginx an — Nginx entscheidet dann wohin die Anfrage geht (Reverse Proxy). listen 80; - # Serve the static React build + # Statische React-Dateien ausliefern (HTML, CSS, JS). + # Vite baut die App in einen dist-Ordner — dieser wird beim Docker-Build nach /usr/share/nginx/html kopiert. location / { root /usr/share/nginx/html; index index.html; - # Fallback to index.html for client-side routing + + # try_files: Nginx sucht zuerst nach einer echten Datei ($uri), + # dann nach einem Ordner ($uri/), und fällt schliesslich auf index.html zurück. + # Warum nötig: React ist eine Single Page Application (SPA) — alle Routen + # existieren nur im Browser, nicht als echte Dateien auf dem Server. + # Ohne diesen Fallback würde ein direkter Aufruf von z.B. /chat einen 404 liefern. try_files $uri $uri/ /index.html; } - # Proxy REST API requests to the backend + # Reverse Proxy: Alle Anfragen an /rooms werden an das Backend weitergeleitet. + # Warum Reverse Proxy statt direkter Backend-URL: Der Browser kennt nur Nginx auf Port 80. + # Das Backend ist im Docker-Netzwerk unter dem Hostnamen "backend" erreichbar — nicht von aussen. + # Vorteil: Backend muss keinen öffentlichen Port exponieren, alles läuft über Nginx. location /rooms { proxy_pass http://backend:4000; proxy_http_version 1.1; + # Host-Header weiterleiten damit das Backend weiss von welcher Domain die Anfrage kommt. proxy_set_header Host $host; + # X-Real-IP: Echte IP-Adresse des Clients weitergeben — sonst sieht das Backend nur Nginx als Absender. proxy_set_header X-Real-IP $remote_addr; + # X-Forwarded-For: Liste aller Proxys durch die die Anfrage gelaufen ist (Audit Trail). proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } - # Proxy the health check endpoint to the backend + # Health-Check-Endpoint wird ebenfalls ans Backend weitergeleitet. + # Wird von Docker genutzt um zu prüfen ob das Backend läuft (siehe healthcheck in docker-compose.yml). location /health { proxy_pass http://backend:4000; proxy_http_version 1.1; } - # Proxy Socket.IO — requires WebSocket upgrade headers + # WebSocket-Proxy für Socket.IO — braucht spezielle Upgrade-Header. + # HTTP und WebSocket sind unterschiedliche Protokolle. Ein WebSocket startet als HTTP-Anfrage + # und wird dann per "Upgrade" auf eine dauerhafte bidirektionale Verbindung umgestellt. + # Ohne diese Header würde Nginx die Verbindung als normale HTTP-Anfrage behandeln + # und der WebSocket-Handshake würde fehlschlagen. location /socket.io/ { proxy_pass http://backend:4000; proxy_http_version 1.1; + # Siehe Zeile 72 in App.jsx proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + # proxy_cache_bypass: Verhindert dass Nginx WebSocket-Verbindungen cached — + # eine dauerhafte Verbindung darf nicht gecacht werden. + # da websocket keine fixe Antwort gibt und es ein laufender Kommunikationskanal ist, kann man es nicht sinnvoll cachen proxy_cache_bypass $http_upgrade; } } diff --git a/frontend/src/App.css b/frontend/src/App.css index 29d1649..61cc4ae 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -131,6 +131,65 @@ button:disabled { margin-top: 0.75rem; } +.rooms-list { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(148, 163, 184, 0.15); +} + +.rooms-list__label { + font-size: 0.8rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0 0 0.5rem; +} + +.rooms-list__chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.room-chip { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 999px; + padding: 0.3rem 0.85rem; + font-size: 0.85rem; + font-weight: 500; + color: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.room-chip:hover { + border-color: rgba(94, 234, 212, 0.6); +} + +.room-chip--active { + border-color: rgba(94, 234, 212, 0.8); + color: #5eead4; +} + +.room-chip__count { + background: rgba(148, 163, 184, 0.2); + border-radius: 999px; + padding: 0.05rem 0.45rem; + font-size: 0.75rem; +} + +.typing-indicator { + padding: 0.4rem 1.5rem; + font-size: 0.82rem; + color: var(--muted); + font-style: italic; + min-height: 1.6rem; + margin: 0; +} + /* Centralised error style classes */ /* Inline text error (e.g. below a field) */ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d17795e..c9001b1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,32 +5,47 @@ import { ERROR_MESSAGES } from './constants/errorMessages' import { validateJoinForm } from './utils/validators' import './App.css' +// VITE_API_URL wird beim Build von Vite eingebettet — so muss die URL nicht hardcoded stehen. +// ?? (Nullish Coalescing): Fallback auf localhost:4000 nur wenn die Variable nicht gesetzt ist. const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000' + function App() { + // t('key') gibt den übersetzten Text aus der aktiven Sprachdatei zurück (z.B. de/translation.json). const { t } = useTranslation() + // Jede State-Variable ist einzeln deklariert statt in einem Objekt. + // So rendert React nur neu wenn sich der jeweilige Wert ändert — nicht bei jeder State-Änderung. const [username, setUsername] = useState('') - const [room, setRoom] = useState('general') - const [session, setSession] = useState(null) - const [status, setStatus] = useState('idle') + const [room, setRoom] = useState('general') // Standardroom beim ersten Laden + const [session, setSession] = useState(null) // null = nicht eingeloggt; sonst { id, username, room } + const [status, setStatus] = useState('idle') // idle | connecting | connected | reconnecting | error const [messages, setMessages] = useState([]) const [participants, setParticipants] = useState([]) const [messageInput, setMessageInput] = useState('') const [error, setError] = useState('') + const [typingUsers, setTypingUsers] = useState([]) // Usernamen der User die gerade tippen + const [rooms, setRooms] = useState([]) // Alle aktiven Rooms für die Chip-Auswahl - const socketRef = useRef(null) - const messagesEndRef = useRef(null) + // useRef speichert Werte ohne ein Re-Render auszulösen — ideal für Socket, DOM-Elemente und Timer. + const socketRef = useRef(null) // Aktive Socket.IO-Verbindung + const messagesEndRef = useRef(null) // Leeres div am Ende der Nachrichtenliste für Auto-Scroll + const typingTimerRef = useRef(null) // Timer-ID für den typing:stop Debounce + // Abgeleitete Werte direkt aus State berechnet — kein eigener useState nötig. const connectionLabel = t(`status.${status}`, { defaultValue: status }) const isConnected = status === 'connected' + // Dieser Effekt verwaltet den gesamten WebSocket-Lebenszyklus. + // Dependency-Array [session?.id, session?.room]: Effekt läuft neu wenn User oder Room wechselt. useEffect(() => { + // Kein Session → Verbindung trennen. Die Cleanup-Funktion am Ende erledigt das. if (!session?.id || !session?.room) { setStatus('idle') return } + // isCancelled verhindert State-Updates nach dem Unmount der Komponente (Memory Leak). let isCancelled = false let retryCount = 0 let reconnectTimer @@ -38,6 +53,11 @@ function App() { const connect = () => { if (isCancelled) return setStatus(retryCount === 0 ? 'connecting' : 'reconnecting') + + // auth wird beim WebSocket-Handshake mitgeschickt — der Server prüft ob userId in der DB existiert + // und ob sie zum angegebenen Room gehört. Wer vorher nicht POST /join aufgerufen hat, + // hat keine gültige userId und wird mit "User not authorized for this room" abgelehnt. + // transports: ['websocket'] erzwingt reines WebSocket, kein HTTP-Polling als Fallback. const socket = io(API_URL, { auth: { room: session.room, @@ -46,6 +66,8 @@ function App() { reconnectionAttempts: 5, transports: ['websocket'], }) + + // Socket in Ref speichern damit handleSendMessage und andere Handler darauf zugreifen können. socketRef.current = socket socket.on('connect', () => { @@ -53,16 +75,15 @@ function App() { setStatus('connected') }) + // socket.io ist der Manager (Verbindungsebene), socket der Namespace (Anwendungsebene). + // Reconnect-Events kommen vom Manager, nicht vom Socket selbst. socket.io.on('reconnect_attempt', () => { - if (!isCancelled) { - setStatus('reconnecting') - } + if (!isCancelled) setStatus('reconnecting') }) + // Alle 5 Reconnect-Versuche gescheitert — User muss manuell neu verbinden. socket.io.on('reconnect_failed', () => { - if (!isCancelled) { - setStatus('error') - } + if (!isCancelled) setStatus('error') }) socket.on('connect_error', (socketError) => { @@ -73,6 +94,9 @@ function App() { socket.on('disconnect', (reason) => { if (isCancelled) return + setTypingUsers([]) + // 'io client disconnect': Der Client hat selbst getrennt (handleLeave) → kein Reconnect. + // Bei allen anderen Gründen (Netzwerkfehler, Server-Neustart) → erneut versuchen. if (reason !== 'io client disconnect' && retryCount < 5) { retryCount += 1 setStatus('reconnecting') @@ -81,25 +105,36 @@ function App() { } }) + // Server schickt beim Verbinden die letzten N Nachrichten des Rooms. socket.on('history:init', (payload) => { setMessages(Array.isArray(payload) ? payload : []) }) + // [...prev, payload] erstellt ein neues Array — React erkennt Änderungen nur per Referenzvergleich. + // prev.push() würde dieselbe Referenz behalten → kein Re-Render. socket.on('message:new', (payload) => { if (payload) { setMessages((prev) => [...prev, payload]) } }) + // Wird bei jedem Join/Leave eines Users im Room gesendet. socket.on('participants:update', (payload) => { setParticipants(Array.isArray(payload) ? payload : []) }) + // Eigener User wird herausgefiltert — man sieht sich selbst nicht als "tippt gerade". + socket.on('typing:update', (payload) => { + if (!Array.isArray(payload)) return + setTypingUsers(payload.filter((u) => u.userId !== session.id).map((u) => u.username)) + }) + socket.on('connection:ready', () => { setError('') }) } + // setTimeout 0ms: Gibt React Zeit das Rendering abzuschliessen bevor der Status gesetzt wird. reconnectTimer = setTimeout(() => { if (!socketRef.current?.connected && !isCancelled) { setStatus('connecting') @@ -108,22 +143,55 @@ function App() { connect() + // Cleanup: Wird ausgeführt wenn die Komponente unmountet oder der Effekt neu startet. + // Ohne Cleanup würden mehrere Sockets gleichzeitig aktiv sein → Memory Leak. return () => { isCancelled = true clearTimeout(reconnectTimer) + clearTimeout(typingTimerRef.current) socketRef.current?.disconnect() socketRef.current = null + setTypingUsers([]) } }, [session?.id, session?.room]) + // Scrollt nach jeder neuen Nachricht ans Ende der Liste — smooth für sanfte Animation. useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) + // Leeres Dependency-Array []: Läuft nur einmal beim ersten Laden der Seite. + useEffect(() => { + fetchRooms() + }, []) + + const fetchRooms = async () => { + try { + const res = await fetch(`${API_URL}/rooms`) + if (res.ok) { + const { rooms: list } = await res.json() + setRooms(list) + } + } catch { + // Rooms sind nicht kritisch — App funktioniert auch ohne die Chip-Navigation. + } + } + const participantCount = participants.length + // useMemo: Text wird nur neu berechnet wenn sich typingUsers ändert — nicht bei jedem Render. + const typingLabel = useMemo(() => { + if (typingUsers.length === 0) return '' + if (typingUsers.length === 1) return `${typingUsers[0]} schreibt…` + if (typingUsers.length === 2) return `${typingUsers[0]} und ${typingUsers[1]} schreiben…` + return `${typingUsers.length} Personen schreiben…` + }, [typingUsers]) + const handleJoin = async (event) => { + // Verhindert das Standard-Browserverhalten: Seite neu laden beim Formular-Submit. event.preventDefault() + + // Client-seitige Validierung vor dem API-Call — spart einen unnötigen Netzwerk-Request. const validationError = validateJoinForm(username, room) if (validationError) { setError(validationError) @@ -133,12 +201,14 @@ function App() { setError('') setStatus(session ? 'reconnecting' : 'connecting') try { + // encodeURIComponent kodiert Sonderzeichen im Room-Namen — z.B. Leerzeichen → %20. + // Ohne Kodierung wäre die URL ungültig und der Request würde scheitern. const response = await fetch(`${API_URL}/rooms/${encodeURIComponent(room.trim())}/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username.trim(), - userId: session?.id, + userId: session?.id, // Bei Room-Wechsel: bestehende userId mitschicken statt neue zu erstellen. }), }) @@ -150,10 +220,19 @@ function App() { if (!data?.user) { throw new Error(ERROR_MESSAGES.api.noUserReturned) } + + // setSession ändert session?.id → WebSocket-useEffect läuft neu → baut neue Verbindung auf. setSession(data.user) setUsername(data.user.username) setRoom(data.user.room) + + // Snapshot per HTTP laden bevor der WebSocket verbunden ist — UI ist nicht leer. + // Der WebSocket überschreibt den Snapshot später mit history:init. await fetchRoomSnapshots(data.user.room) + fetchRooms() + if (socketRef.current?.connected) { + setStatus('connected') + } } catch (joinError) { console.error(joinError) setError(joinError.message) @@ -161,6 +240,7 @@ function App() { } } + // Promise.all führt beide Requests parallel aus — Gesamtdauer = max(t1, t2) statt t1 + t2. const fetchRoomSnapshots = async (targetRoom) => { const encodedRoom = encodeURIComponent(targetRoom) try { @@ -177,20 +257,38 @@ function App() { setParticipants(list) } } catch (snapshotError) { + // Nicht kritisch — WebSocket liefert history:init und participants:update nach. console.warn(t(ERROR_MESSAGES.api.snapshotFailed), snapshotError) } } + // Debounce-Muster: typing:start sofort senden, typing:stop erst nach 1500ms Pause. + // clearTimeout vor jedem neuen Timeout — Timer wird bei jedem Tastendruck zurückgesetzt. + const handleMessageInputChange = (event) => { + setMessageInput(event.target.value) + const socket = socketRef.current + if (!socket?.connected) return + socket.emit('typing:start') + clearTimeout(typingTimerRef.current) + typingTimerRef.current = setTimeout(() => { + socket.emit('typing:stop') + }, 1500) + } + + // Nachricht per WebSocket senden — nicht per HTTP. + // Vorteil: Keine neue Verbindung pro Nachricht, Server broadcastet direkt an alle im Room. + // Datenfluss: emit('message:send') → Server speichert → Server sendet 'message:new' an alle. const handleSendMessage = (event) => { event.preventDefault() - if (!session || !messageInput.trim()) { - return - } + if (!session || !messageInput.trim()) return const socket = socketRef.current if (!socket || !socket.connected) { setError(ERROR_MESSAGES.connection.noActiveConnection) return } + // Tipp-Indikator sofort beenden — nicht auf die 1500ms warten. + clearTimeout(typingTimerRef.current) + socket.emit('typing:stop') try { socket.emit('message:send', { text: messageInput.trim(), @@ -203,8 +301,12 @@ function App() { } } + // finally läuft immer — egal ob HTTP-Request erfolgreich war oder nicht. + // setSession(null) löst den WebSocket-useEffect aus → Verbindung wird sauber getrennt. const handleLeave = async () => { if (!session) return + clearTimeout(typingTimerRef.current) + socketRef.current?.emit('typing:stop') try { await fetch(`${API_URL}/rooms/${encodeURIComponent(session.room)}/leave`, { method: 'POST', @@ -212,15 +314,19 @@ function App() { body: JSON.stringify({ userId: session.id }), }) } catch (leaveError) { + // HTTP-Fehler ignorieren — lokaler State wird in finally trotzdem zurückgesetzt. console.warn(t(ERROR_MESSAGES.api.leaveFailed), leaveError) } finally { setSession(null) setMessages([]) setParticipants([]) + setTypingUsers([]) setStatus('idle') + fetchRooms() } } + // useMemo: Kontextsensitiver Hilfstext — wird nur neu berechnet wenn session, status oder Sprache wechselt. const connectionHint = useMemo(() => { if (!session) return t('hint.noSession') switch (status) { @@ -239,6 +345,7 @@ function App() {

{t('app.headline')}

{connectionHint}

+ {/* CSS-Klasse wird dynamisch gesetzt: status-connected, status-error etc. */}
{connectionLabel} @@ -249,6 +356,7 @@ function App() {
+ {/* Controlled Input: value kommt aus State, onChange aktualisiert State. */} setRoom(event.target.value.toLowerCase())} + // toLowerCase() verhindert doppelte Rooms durch unterschiedliche Schreibweise ("General" vs "general"). />
+ {/* Leave-Button nur anzeigen wenn eine Session aktiv ist (Conditional Rendering). */} {session ? (
{error ?

{t(error)}

: null} + {rooms.length > 0 && ( +
+

{t('rooms.title')}

+
+ {rooms.map((r) => ( + + ))} +
+
+ )}
@@ -284,6 +414,7 @@ function App() {

{t('chat.title')}

+ {/* ?? zeigt session?.room wenn vorhanden, sonst den Wert aus dem Input-Feld. */}

{t('chat.liveFeed', { room: session?.room ?? room })}

{t('chat.messageCount', { count: messages.length })} @@ -296,6 +427,7 @@ function App() {
{message.username} @@ -305,23 +437,31 @@ function App() {
)) )} + {/* Leeres div als Scroll-Anker — scrollIntoView() hier = ans Ende scrollen. */}
+ {/* Tipp-Indikator nur anzeigen wenn typingLabel nicht leer ist. */} + {typingLabel && ( +

{typingLabel}

+ )}
setMessageInput(event.target.value)} + onChange={handleMessageInputChange} placeholder={session ? t('chat.placeholder') : t('chat.placeholderDisabled')} disabled={!session || !isConnected} + // Deaktiviert wenn kein Session oder WebSocket nicht verbunden — kein Senden ohne Verbindung. />
+