Compare commits

2 Commits

Author SHA1 Message Date
b7365d2127 README.md aktualisiert 2025-08-19 10:39:08 +02:00
5153d7d7de README.md gelöscht 2025-08-19 10:38:38 +02:00
17 changed files with 146 additions and 1464 deletions

View File

@@ -51,12 +51,6 @@ EXPORT_PATH=./exports/ # Speicherort für SVG-Exports
ENABLE_AUTOMATIC_EVENTS=false # Automatische Events deaktiviert ENABLE_AUTOMATIC_EVENTS=false # Automatische Events deaktiviert
EVENT_DURATION_MINUTES=30 # Event-Dauer EVENT_DURATION_MINUTES=30 # Event-Dauer
EVENT_INTERVAL_HOURS=6 # Abstand zwischen Events EVENT_INTERVAL_HOURS=6 # Abstand zwischen Events
# Keycloak Authentifizierung (optional)
ENABLE_KEYCLOAK=false # Keycloak-Authentifizierung
KEYCLOAK_REALM=rplace # Keycloak Realm Name
KEYCLOAK_AUTH_URL=http://localhost:8080 # Keycloak Server URL
KEYCLOAK_CLIENT_ID=rplace-client # Keycloak Client ID Events
``` ```
## Schritt 4: PostgreSQL Datenbank einrichten (optional) ## Schritt 4: PostgreSQL Datenbank einrichten (optional)

View File

@@ -1,96 +0,0 @@
# Keycloak Setup für r/place
## Keycloak Installation
### Docker (Empfohlen)
```bash
docker run -d \
--name keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest \
start-dev
```
### Standalone Installation
1. Lade Keycloak von https://www.keycloak.org/downloads herunter
2. Entpacke das Archiv
3. Starte Keycloak: `bin/kc.sh start-dev`
## Konfiguration
### 1. Admin Console öffnen
Gehe zu http://localhost:8080/admin und melde dich mit admin/admin an.
### 2. Realm erstellen
1. Klicke auf "Create Realm"
2. Name: `rplace`
3. Klicke "Create"
### 3. Client erstellen
1. Gehe zu "Clients" → "Create client"
2. Client ID: `rplace-client`
3. Client type: `OpenID Connect`
4. Klicke "Next"
5. Client authentication: `OFF` (Public client)
6. Standard flow: `ON`
7. Direct access grants: `ON`
8. Klicke "Save"
### 4. Client Settings
1. Gehe zu deinem Client `rplace-client`
2. Settings Tab:
- Valid redirect URIs: `http://localhost:5000/*`
- Valid post logout redirect URIs: `http://localhost:5000/*`
- Web origins: `http://localhost:5000`
3. Klicke "Save"
### 5. Test User erstellen
1. Gehe zu "Users" → "Add user"
2. Username: `testuser`
3. Klicke "Create"
4. Gehe zum "Credentials" Tab
5. Setze ein Passwort und deaktiviere "Temporary"
## r/place Konfiguration
Bearbeite `config.cfg`:
```ini
ENABLE_KEYCLOAK=true
KEYCLOAK_REALM=rplace
KEYCLOAK_AUTH_URL=http://localhost:8080
KEYCLOAK_CLIENT_ID=rplace-client
```
## Erweiterte Konfiguration
### HTTPS (Produktion)
Für Produktionsumgebungen:
```ini
KEYCLOAK_AUTH_URL=https://dein-keycloak-server.de
```
### Benutzer-Attribute
Du kannst zusätzliche Benutzerattribute in Keycloak konfigurieren:
1. Gehe zu "Client scopes"
2. Bearbeite "profile" scope
3. Füge Mappers für zusätzliche Attribute hinzu
### Sicherheit
- Ändere Admin-Passwort
- Konfiguriere SSL/TLS
- Setze starke Passwort-Richtlinien
- Aktiviere Brute-Force-Schutz
## Fehlerbehebung
### CORS-Probleme
Stelle sicher, dass die Web origins korrekt konfiguriert sind.
### Token-Probleme
Überprüfe die Client-Konfiguration und Redirect-URIs.
### Verbindungsprobleme
Stelle sicher, dass Keycloak erreichbar ist und die URLs korrekt sind.

View File

@@ -0,0 +1,33 @@
# r/place - Schnellstart
## Sofort starten (Entwicklung)
```bash
npm install
npm run dev
```
Dann öffne: http://localhost:5000
## Konfiguration
Bearbeite `config.cfg` für:
- Canvas-Größe (CANVAS_WIDTH, CANVAS_HEIGHT)
- Cooldown-Zeit (DEFAULT_COOLDOWN_SECONDS)
- Export-Intervall (AUTO_EXPORT_INTERVAL_SECONDS)
## Wichtige Dateien
- `config.cfg` - Alle Einstellungen
- `exports/` - Automatische SVG-Exports
- `INSTALLATION.md` - Detaillierte Server-Installation
## Features
✓ 32 offizielle r/place 2022 Farben
✓ Koordinatensystem mit Achsenbeschriftung
✓ Live-Mauskoordinaten
✓ Automatische SVG-Exports
✓ WebSocket Live-Updates
✓ Cooldown-System
✓ Deutsche Benutzeroberfläche

View File

@@ -1,33 +0,0 @@
# r/place - Schnellstart
## Sofort starten (Entwicklung)
```bash
npm install
npm run dev
```
Dann öffne: http://localhost:5000
## Konfiguration
Bearbeite `config.cfg` für:
- Canvas-Größe (CANVAS_WIDTH, CANVAS_HEIGHT)
- Cooldown-Zeit (DEFAULT_COOLDOWN_SECONDS)
- Export-Intervall (AUTO_EXPORT_INTERVAL_SECONDS)
## Wichtige Dateien
- `config.cfg` - Alle Einstellungen
- `exports/` - Automatische SVG-Exports
- `INSTALLATION.md` - Detaillierte Server-Installation
## Features
✓ 32 offizielle r/place 2022 Farben
✓ Koordinatensystem mit Achsenbeschriftung
✓ Live-Mauskoordinaten
✓ Automatische SVG-Exports
✓ WebSocket Live-Updates
✓ Cooldown-System
✓ Deutsche Benutzeroberfläche

View File

@@ -1,58 +0,0 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { getAuthStatus, type AuthStatus } from "@/lib/config";
export function AuthBanner() {
const [authStatus, setAuthStatus] = useState<AuthStatus | null>(null);
useEffect(() => {
getAuthStatus().then(setAuthStatus);
}, []);
if (!authStatus?.keycloakEnabled) {
return null;
}
if (authStatus.authenticated) {
return (
<div className="bg-green-50 border-b border-green-200 p-3">
<div className="flex items-center justify-between max-w-7xl mx-auto">
<div className="flex items-center gap-2">
<span className="text-green-700 font-medium">
Angemeldet als {authStatus.user?.username}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => window.location.href = "/logout"}
className="border-green-300 text-green-700 hover:bg-green-100"
>
Abmelden
</Button>
</div>
</div>
);
}
return (
<div className="bg-blue-50 border-b border-blue-200 p-3">
<div className="flex items-center justify-between max-w-7xl mx-auto">
<div className="flex items-center gap-2">
<span className="text-blue-700">
Melde dich an, um Pixel zu platzieren
</span>
</div>
<Button
onClick={() => window.location.href = "/login"}
className="bg-blue-600 hover:bg-blue-700 text-white"
size="sm"
>
Anmelden
</Button>
</div>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { Pixel } from "@shared/schema"; import { Pixel } from "@shared/schema";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -23,11 +22,9 @@ export function OptimizedCanvas({
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const pixelSize = Math.max(2, 8 * zoom); const [pixelSize, setPixelSize] = useState(8);
const [mouseCoords, setMouseCoords] = useState<{x: number, y: number} | null>(null); const [mouseCoords, setMouseCoords] = useState<{x: number, y: number} | null>(null);
const [previewPixel, setPreviewPixel] = useState<{x: number, y: number} | null>(null); const [previewPixel, setPreviewPixel] = useState<{x: number, y: number} | null>(null);
const [isPanning, setIsPanning] = useState(false);
const [lastPanPosition, setLastPanPosition] = useState<{x: number, y: number} | null>(null);
// Create pixel map for O(1) lookup // Create pixel map for O(1) lookup
const pixelMap = new Map<string, string>(); const pixelMap = new Map<string, string>();
@@ -120,6 +117,10 @@ export function OptimizedCanvas({
drawCanvas(); drawCanvas();
}, [drawCanvas]); }, [drawCanvas]);
useEffect(() => {
setPixelSize(Math.max(2, 8 * zoom));
}, [zoom]);
const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => { const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return null; if (!canvas) return null;
@@ -157,114 +158,18 @@ export function OptimizedCanvas({
const handleCanvasMouseLeave = () => { const handleCanvasMouseLeave = () => {
setMouseCoords(null); setMouseCoords(null);
setPreviewPixel(null); setPreviewPixel(null);
setIsPanning(false);
setLastPanPosition(null);
};
const handleMouseDown = (e: React.MouseEvent) => {
// Mittlere Maustaste (Button 1)
if (e.button === 1) {
e.preventDefault();
setIsPanning(true);
setLastPanPosition({ x: e.clientX, y: e.clientY });
}
};
const handleMouseUp = (e: React.MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
setIsPanning(false);
setLastPanPosition(null);
}
};
const handleMouseMoveContainer = (e: React.MouseEvent) => {
if (isPanning && lastPanPosition && containerRef.current) {
e.preventDefault();
const deltaX = e.clientX - lastPanPosition.x;
const deltaY = e.clientY - lastPanPosition.y;
const container = containerRef.current;
container.scrollLeft -= deltaX;
container.scrollTop -= deltaY;
setLastPanPosition({ x: e.clientX, y: e.clientY });
}
};
const zoomToPoint = (newZoom: number, mouseX?: number, mouseY?: number) => {
const container = containerRef.current;
if (!container) return;
const clampedZoom = Math.max(0.1, Math.min(newZoom, 5));
if (Math.abs(clampedZoom - zoom) < 0.01) return;
const oldPixelSize = pixelSize;
const newPixelSize = Math.max(2, 8 * clampedZoom);
// Aktuelle Scroll-Position
const currentScrollLeft = container.scrollLeft;
const currentScrollTop = container.scrollTop;
let zoomPointX, zoomPointY;
if (mouseX !== undefined && mouseY !== undefined) {
// Zoomen an Mausposition
const containerRect = container.getBoundingClientRect();
const relativeMouseX = mouseX - containerRect.left;
const relativeMouseY = mouseY - containerRect.top;
// Canvas-Koordinaten der Mausposition
zoomPointX = (currentScrollLeft + relativeMouseX - 32) / oldPixelSize;
zoomPointY = (currentScrollTop + relativeMouseY - 32) / oldPixelSize;
setZoom(clampedZoom);
// Neue Scroll-Position berechnen, damit Mausposition gleich bleibt
requestAnimationFrame(() => {
const newScrollLeft = zoomPointX * newPixelSize + 32 - relativeMouseX;
const newScrollTop = zoomPointY * newPixelSize + 32 - relativeMouseY;
container.scrollTo({
left: Math.max(0, Math.min(newScrollLeft, container.scrollWidth - container.clientWidth)),
top: Math.max(0, Math.min(newScrollTop, container.scrollHeight - container.clientHeight)),
behavior: 'auto'
});
});
} else {
// Zoomen im Center des Viewports
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
zoomPointX = (currentScrollLeft + containerWidth / 2 - 32) / oldPixelSize;
zoomPointY = (currentScrollTop + containerHeight / 2 - 32) / oldPixelSize;
setZoom(clampedZoom);
requestAnimationFrame(() => {
const newScrollLeft = zoomPointX * newPixelSize + 32 - containerWidth / 2;
const newScrollTop = zoomPointY * newPixelSize + 32 - containerHeight / 2;
container.scrollTo({
left: Math.max(0, Math.min(newScrollLeft, container.scrollWidth - container.clientWidth)),
top: Math.max(0, Math.min(newScrollTop, container.scrollHeight - container.clientHeight)),
behavior: 'smooth'
});
});
}
}; };
const handleZoomIn = () => { const handleZoomIn = () => {
zoomToPoint(zoom * 1.2); setZoom(prev => Math.min(prev * 1.2, 3));
}; };
const handleZoomOut = () => { const handleZoomOut = () => {
zoomToPoint(zoom / 1.2); setZoom(prev => Math.max(prev / 1.2, 0.5));
}; };
const handleResetZoom = () => { const handleResetZoom = () => {
zoomToPoint(1); setZoom(1);
}; };
const handleWheel = (e: React.WheelEvent) => { const handleWheel = (e: React.WheelEvent) => {
@@ -273,39 +178,23 @@ export function OptimizedCanvas({
const zoomFactor = 1.1; const zoomFactor = 1.1;
const delta = e.deltaY; const delta = e.deltaY;
let newZoom;
if (delta < 0) { if (delta < 0) {
newZoom = zoom * zoomFactor; setZoom(prev => Math.min(prev * zoomFactor, 3));
} else { } else {
newZoom = zoom / zoomFactor; setZoom(prev => Math.max(prev / zoomFactor, 0.5));
} }
zoomToPoint(newZoom, e.clientX, e.clientY);
}; };
return ( return (
<div className="flex-1 relative bg-canvas-bg overflow-hidden"> <div className="flex-1 relative bg-canvas-bg overflow-hidden">
<div <div
ref={containerRef} ref={containerRef}
className={cn( className="w-full h-full overflow-auto p-8 scroll-smooth canvas-container"
"w-full h-full overflow-auto p-8 canvas-container",
isPanning && "cursor-grabbing select-none"
)}
onWheel={handleWheel} onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMoveContainer}
onMouseLeave={() => {
setIsPanning(false);
setLastPanPosition(null);
}}
data-testid="canvas-container" data-testid="canvas-container"
style={{
scrollBehavior: isPanning ? 'auto' : 'auto'
}}
> >
{/* Coordinate System Container */} {/* Coordinate System Container */}
<div className="relative inline-block"> <div className="relative inline-block canvas-zoom">
{/* Top X-axis coordinates */} {/* Top X-axis coordinates */}
<div className="flex ml-8 mb-1"> <div className="flex ml-8 mb-1">
{Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => ( {Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => (
@@ -314,7 +203,7 @@ export function OptimizedCanvas({
className="text-xs text-gray-400 text-center" className="text-xs text-gray-400 text-center"
style={{ style={{
width: `${10 * pixelSize}px`, width: `${10 * pixelSize}px`,
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px` fontSize: `${Math.max(8, pixelSize * 0.8)}px`
}} }}
> >
{i * 10} {i * 10}
@@ -333,7 +222,7 @@ export function OptimizedCanvas({
style={{ style={{
height: `${10 * pixelSize}px`, height: `${10 * pixelSize}px`,
width: '24px', width: '24px',
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px` fontSize: `${Math.max(8, pixelSize * 0.8)}px`
}} }}
> >
{i * 10} {i * 10}
@@ -345,13 +234,12 @@ export function OptimizedCanvas({
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className={cn( className={cn(
"border border-gray-400", "border border-gray-400 cursor-pointer",
isPanning ? "cursor-grabbing" : cooldownActive ? "cursor-not-allowed" : "cursor-pointer" cooldownActive && "cursor-not-allowed"
)} )}
onClick={handleCanvasClick} onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove} onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave} onMouseLeave={handleCanvasMouseLeave}
onContextMenu={(e) => e.preventDefault()} // Verhindert Rechtsklick-Menü
data-testid="pixel-canvas" data-testid="pixel-canvas"
/> />
</div> </div>
@@ -359,63 +247,48 @@ export function OptimizedCanvas({
</div> </div>
{/* Zoom Controls */} {/* Zoom Controls */}
<div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/90 p-3 rounded-lg shadow-lg"> <div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/80 p-2 rounded shadow">
<button <button
onClick={handleZoomIn} onClick={handleZoomIn}
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors" className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
data-testid="button-zoom-in" data-testid="button-zoom-in"
disabled={zoom >= 5}
> >
+ +
</button> </button>
<button <button
onClick={handleZoomOut} onClick={handleZoomOut}
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors" className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
data-testid="button-zoom-out" data-testid="button-zoom-out"
disabled={zoom <= 0.1}
> >
- -
</button> </button>
<button <button
onClick={handleResetZoom} onClick={handleResetZoom}
className="px-2 py-1 bg-gray-500 text-white rounded text-xs font-semibold hover:bg-gray-600 transition-colors" className="px-2 py-1 bg-gray-500 text-white rounded text-xs hover:bg-gray-600"
data-testid="button-zoom-reset" data-testid="button-zoom-reset"
> >
100% 100%
</button> </button>
<div className="text-xs text-gray-600 text-center font-mono">
{Math.round(zoom * 100)}%
</div>
</div> </div>
{/* Info Display */} {/* Info Display */}
<div className="absolute bottom-4 left-4 bg-white/90 p-3 rounded-lg shadow-lg text-sm"> <div className="absolute bottom-4 left-4 bg-white/80 p-3 rounded shadow text-sm">
<div className="text-xs text-gray-600 font-semibold"> <div className="text-xs text-gray-600">
Canvas: {canvasWidth}×{canvasHeight} Canvas: {canvasWidth}x{canvasHeight}
</div> </div>
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-400">
Pixel: {pixelSize}px | Zoom: {Math.round(zoom * 100)}% Zoom: {Math.round(zoom * 100)}%
</div> </div>
{mouseCoords && ( {mouseCoords && (
<div className="text-xs text-green-600 mt-2 font-mono"> <div className="text-xs text-green-400 mt-1">
Position: ({mouseCoords.x}, {mouseCoords.y}) Mouse: ({mouseCoords.x}, {mouseCoords.y})
</div> </div>
)} )}
{previewPixel && !cooldownActive && ( {previewPixel && !cooldownActive && (
<div className="text-xs text-blue-600 mt-1"> <div className="text-xs text-blue-400 mt-1">
Vorschau: {selectedColor} Vorschau: {selectedColor}
</div> </div>
)} )}
{cooldownActive && (
<div className="text-xs text-red-500 mt-1 font-semibold">
Cooldown aktiv
</div>
)}
{isPanning && (
<div className="text-xs text-blue-500 mt-1 font-semibold">
Bewege Canvas (Mittlere Maustaste)
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -131,49 +131,31 @@
100% { background-position: 20px 20px; } 100% { background-position: 20px 20px; }
} }
/* Canvas container optimiert für glattes Zoomen */ /* Smooth scrolling für den gesamten Container */
.scroll-smooth {
scroll-behavior: smooth;
}
/* Canvas zoom transitions */
.canvas-zoom {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Geschmeidiges Mausrad-Scrolling */
.canvas-container { .canvas-container {
scroll-behavior: auto; /* Für präzises Zoom-Verhalten */ scroll-behavior: smooth;
} }
.canvas-container:not(:hover) { /* Optimierte Pixel-Hover-Effekte */
scroll-behavior: smooth; /* Smooth nur wenn nicht gehovered */ .pixel {
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out, opacity 0.1s ease-out;
} }
/* Zoom Controls */ .pixel:hover {
.canvas-container * { transform: scale(1.1);
image-rendering: pixelated; z-index: 10;
image-rendering: -moz-crisp-edges; position: relative;
image-rendering: crisp-edges; box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
}
/* Optimierte Performance für große Canvas */
.canvas-container canvas {
will-change: transform;
backface-visibility: hidden;
}
/* Smooth transitions für UI Elemente */
.zoom-controls {
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* Pixel-perfekte Rendering */
canvas {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
/* Verbesserte Hover-Effekte */
.canvas-container:hover {
cursor: crosshair;
}
/* Info Panel Styling */
.info-panel {
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
} }
/* Pixel-Vorschau */ /* Pixel-Vorschau */

View File

@@ -29,7 +29,7 @@ export const COLORS = [
"#ffb470", // Beige "#ffb470", // Beige
"#000000", // Black "#000000", // Black
"#515252", // Dark Gray "#515252", // Dark Gray
"#898989", // Gray "#898d90", // Gray
"#d4d7d9", // Light Gray "#d4d7d9", // Light Gray
"#ffffff", // White "#ffffff", // White
] as const; ] as const;
@@ -48,24 +48,3 @@ export function generateUserId(): string {
export function getUsername(): string { export function getUsername(): string {
return generateUserId(); return generateUserId();
} }
export const API_BASE = "/api";
export interface AuthStatus {
authenticated: boolean;
keycloakEnabled: boolean;
user?: {
userId: string;
username: string;
};
}
export async function getAuthStatus(): Promise<AuthStatus> {
try {
const response = await fetch(`${API_BASE}/auth/status`);
return await response.json();
} catch (error) {
console.error("Failed to get auth status:", error);
return { authenticated: false, keycloakEnabled: false };
}
}

View File

@@ -11,7 +11,6 @@ import { useToast } from "@/hooks/use-toast";
import { DEFAULT_SELECTED_COLOR, generateUserId, getUsername } from "@/lib/config"; import { DEFAULT_SELECTED_COLOR, generateUserId, getUsername } from "@/lib/config";
import { Pixel, CanvasConfig, InsertPixel, WSMessage } from "@shared/schema"; import { Pixel, CanvasConfig, InsertPixel, WSMessage } from "@shared/schema";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import { AuthBanner } from "@/components/auth-banner";
export default function CanvasPage() { export default function CanvasPage() {
const [selectedColor, setSelectedColor] = useState(DEFAULT_SELECTED_COLOR); const [selectedColor, setSelectedColor] = useState(DEFAULT_SELECTED_COLOR);
@@ -147,7 +146,6 @@ export default function CanvasPage() {
return ( return (
<div className="h-screen flex flex-col bg-canvas-bg text-white"> <div className="h-screen flex flex-col bg-canvas-bg text-white">
<AuthBanner />
{/* Header */} {/* Header */}
<header className="bg-panel-bg border-b border-gray-700 px-4 py-3 flex items-center justify-between"> <header className="bg-panel-bg border-b border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">

View File

@@ -2,11 +2,11 @@
# Ändere diese Werte um die Canvas-Einstellungen anzupassen # Ändere diese Werte um die Canvas-Einstellungen anzupassen
# Canvas Dimensionen # Canvas Dimensionen
CANVAS_WIDTH=500 CANVAS_WIDTH=100
CANVAS_HEIGHT=200 CANVAS_HEIGHT=100
# Cooldown Einstellungen (in Sekunden) # Cooldown Einstellungen (in Sekunden)
DEFAULT_COOLDOWN=10 DEFAULT_COOLDOWN=5
# Automatische Events (true/false) # Automatische Events (true/false)
# Wenn aktiviert, gibt es keine Cooldowns # Wenn aktiviert, gibt es keine Cooldowns
@@ -14,16 +14,10 @@ ENABLE_AUTOMATIC_EVENTS=false
# Event Einstellungen # Event Einstellungen
EVENT_DURATION_MINUTES=30 EVENT_DURATION_MINUTES=30
EVENT_INTERVAL_HOURS=1 EVENT_INTERVAL_HOURS=6
# Grid-Funktionalität wurde entfernt
# Export Einstellungen # Export Einstellungen
AUTO_EXPORT_INTERVAL_SECONDS=60 AUTO_EXPORT_INTERVAL_SECONDS=60
EXPORT_PATH=./exports/ EXPORT_PATH=./exports/
# Keycloak Einstellungen
ENABLE_KEYCLOAK=false
KEYCLOAK_REALM=rplace
KEYCLOAK_AUTH_URL=http://localhost:8080/auth
KEYCLOAK_CLIENT_ID=rplace-client

875
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -51,10 +51,9 @@
"drizzle-zod": "^0.7.0", "drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"express": "^4.21.2", "express": "^4.21.2",
"express-session": "^1.18.2", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"keycloak-connect": "^26.1.1",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",

View File

@@ -11,11 +11,6 @@ interface Config {
autoExportIntervalSeconds: number; autoExportIntervalSeconds: number;
exportPath: string; exportPath: string;
enableKeycloak: boolean;
keycloakRealm: string;
keycloakAuthUrl: string;
keycloakClientId: string;
} }
function parseConfigFile(): Config { function parseConfigFile(): Config {
@@ -59,18 +54,6 @@ function parseConfigFile(): Config {
case "EXPORT_PATH": case "EXPORT_PATH":
config.exportPath = trimmedValue; config.exportPath = trimmedValue;
break; break;
case "ENABLE_KEYCLOAK":
config.enableKeycloak = trimmedValue.toLowerCase() === "true";
break;
case "KEYCLOAK_REALM":
config.keycloakRealm = trimmedValue;
break;
case "KEYCLOAK_AUTH_URL":
config.keycloakAuthUrl = trimmedValue;
break;
case "KEYCLOAK_CLIENT_ID":
config.keycloakClientId = trimmedValue;
break;
} }
}); });
@@ -85,11 +68,6 @@ function parseConfigFile(): Config {
autoExportIntervalSeconds: config.autoExportIntervalSeconds || 60, autoExportIntervalSeconds: config.autoExportIntervalSeconds || 60,
exportPath: config.exportPath || "./exports/", exportPath: config.exportPath || "./exports/",
enableKeycloak: config.enableKeycloak || false,
keycloakRealm: config.keycloakRealm || "rplace",
keycloakAuthUrl: config.keycloakAuthUrl || "http://localhost:8080/auth",
keycloakClientId: config.keycloakClientId || "rplace-client",
}; };
} catch (error) { } catch (error) {
console.error("Error reading config file, using defaults:", error); console.error("Error reading config file, using defaults:", error);
@@ -103,11 +81,6 @@ function parseConfigFile(): Config {
autoExportIntervalSeconds: 60, autoExportIntervalSeconds: 60,
exportPath: "./exports/", exportPath: "./exports/",
enableKeycloak: false,
keycloakRealm: "rplace",
keycloakAuthUrl: "http://localhost:8080/auth",
keycloakClientId: "rplace-client",
}; };
} }
} }

View File

@@ -1,27 +1,11 @@
import express, { type Request, Response, NextFunction } from "express"; import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes"; import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite"; import { setupVite, serveStatic, log } from "./vite";
import { setupKeycloak } from "./keycloak";
import { config } from "./config";
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
// Keycloak Setup
let keycloak: any = null;
if (config.enableKeycloak) {
// Set environment variables for Keycloak
process.env.KEYCLOAK_REALM = config.keycloakRealm;
process.env.KEYCLOAK_AUTH_URL = config.keycloakAuthUrl;
process.env.KEYCLOAK_CLIENT_ID = config.keycloakClientId;
keycloak = setupKeycloak(app);
log("Keycloak authentication enabled");
} else {
log("Keycloak authentication disabled");
}
app.use((req, res, next) => { app.use((req, res, next) => {
const start = Date.now(); const start = Date.now();
const path = req.path; const path = req.path;
@@ -76,7 +60,7 @@ app.use((req, res, next) => {
// Other ports are firewalled. Default to 5000 if not specified. // Other ports are firewalled. Default to 5000 if not specified.
// this serves both the API and the client. // this serves both the API and the client.
// It is the only port that is not firewalled. // It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5001', 10); const port = parseInt(process.env.PORT || '5000', 10);
server.listen({ server.listen({
port, port,
host: "0.0.0.0", host: "0.0.0.0",

View File

@@ -1,56 +0,0 @@
import Keycloak from 'keycloak-connect';
import session from 'express-session';
import { type Express } from 'express';
interface KeycloakConfig {
realm: string;
'auth-server-url': string;
'ssl-required': string;
resource: string;
'public-client': boolean;
'confidential-port': number;
}
// Keycloak Konfiguration aus Umgebungsvariablen oder Standard
const keycloakConfig: KeycloakConfig = {
realm: process.env.KEYCLOAK_REALM || 'rplace',
'auth-server-url': process.env.KEYCLOAK_AUTH_URL || 'http://localhost:8080/auth',
'ssl-required': 'external',
resource: process.env.KEYCLOAK_CLIENT_ID || 'rplace-client',
'public-client': true,
'confidential-port': 0,
};
// Session Store für Keycloak
const memoryStore = session.MemoryStore ? new session.MemoryStore() : undefined;
export function setupKeycloak(app: Express) {
// Session Middleware
const sessionConfig = {
secret: process.env.SESSION_SECRET || 'rplace-secret-key',
resave: false,
saveUninitialized: true,
store: memoryStore,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 Stunden
},
};
app.use(session(sessionConfig));
// Keycloak initialisieren
const keycloak = new Keycloak({ store: memoryStore }, keycloakConfig);
// Keycloak Middleware
app.use(keycloak.middleware({
logout: '/logout',
admin: '/',
}));
return keycloak;
}
export { keycloakConfig };

View File

@@ -1,42 +1,9 @@
import type { Express, Request, Response, NextFunction } from "express"; import type { Express } from "express";
import { createServer, type Server } from "http"; import { createServer, type Server } from "http";
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from "ws";
import { storage } from "./storage"; import { storage } from "./storage";
import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@shared/schema"; import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@shared/schema";
import { CanvasExporter } from "./export"; import { CanvasExporter } from "./export";
import { config } from "./config";
// Authentication middleware
function requireAuth(req: Request, res: Response, next: NextFunction) {
if (!config.enableKeycloak) {
return next();
}
// Check if user is authenticated via Keycloak
if (req.kauth && req.kauth.grant) {
return next();
}
return res.status(401).json({ message: "Authentication required" });
}
// Get user info from Keycloak token
function getUserFromToken(req: Request): { userId: string; username: string } {
if (!config.enableKeycloak || !req.kauth?.grant?.access_token) {
return {
userId: "User",
username: "Anonymous"
};
}
const token = req.kauth.grant.access_token;
const content = token.content;
return {
userId: content.sub || content.preferred_username || "User",
username: content.preferred_username || content.name || "User"
};
}
export async function registerRoutes(app: Express): Promise<Server> { export async function registerRoutes(app: Express): Promise<Server> {
const httpServer = createServer(app); const httpServer = createServer(app);
@@ -48,38 +15,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
const exporter = new CanvasExporter(storage); const exporter = new CanvasExporter(storage);
exporter.startAutoExport(); exporter.startAutoExport();
// Authentication Routes
app.get("/api/auth/status", (req, res) => {
if (!config.enableKeycloak) {
return res.json({ authenticated: false, keycloakEnabled: false });
}
const isAuthenticated = req.kauth && req.kauth.grant;
const user = isAuthenticated ? getUserFromToken(req) : null;
res.json({
authenticated: isAuthenticated,
keycloakEnabled: true,
user: user
});
});
// Login redirect
app.get("/login", (req, res) => {
if (config.enableKeycloak && req.kauth) {
return req.kauth.login(req, res);
}
res.redirect("/");
});
// Logout
app.get("/logout", (req, res) => {
if (config.enableKeycloak && req.kauth) {
return req.kauth.logout(req, res);
}
res.redirect("/");
});
// API Routes // API Routes
app.get("/api/pixels", async (req, res) => { app.get("/api/pixels", async (req, res) => {
try { try {
@@ -102,14 +37,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Config is now read-only from file // Config is now read-only from file
// Remove the POST endpoint for config updates // Remove the POST endpoint for config updates
app.post("/api/pixels", requireAuth, async (req, res) => { app.post("/api/pixels", async (req, res) => {
try { try {
const userInfo = getUserFromToken(req); const pixelData = insertPixelSchema.parse(req.body);
const pixelData = insertPixelSchema.parse({
...req.body,
userId: userInfo.userId,
username: userInfo.username
});
const config = await storage.getCanvasConfig(); const config = await storage.getCanvasConfig();
// Validate coordinates // Validate coordinates
@@ -120,7 +50,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check cooldown unless events are enabled // Check cooldown unless events are enabled
if (!config.enableAutomaticEvents) { if (!config.enableAutomaticEvents) {
const cooldown = await storage.getUserCooldown(userInfo.userId); const cooldown = await storage.getUserCooldown(pixelData.userId);
if (cooldown && cooldown.cooldownEnds > new Date()) { if (cooldown && cooldown.cooldownEnds > new Date()) {
return res.status(429).json({ message: "Cooldown active" }); return res.status(429).json({ message: "Cooldown active" });
} }
@@ -128,7 +58,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Set new cooldown // Set new cooldown
const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000)); const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000));
await storage.setUserCooldown({ await storage.setUserCooldown({
userId: userInfo.userId, userId: pixelData.userId,
cooldownEnds: cooldownEnd, cooldownEnds: cooldownEnd,
}); });
} }

View File

@@ -1,21 +0,0 @@
import 'express';
declare module 'express' {
interface Request {
kauth?: {
grant?: {
access_token?: {
content?: {
sub?: string;
preferred_username?: string;
name?: string;
email?: string;
};
};
};
login?: (req: Request, res: Response) => void;
logout?: (req: Request, res: Response) => void;
};
}
}