Compare commits

...

3 Commits

Author SHA1 Message Date
68eeaa063d coldown fix 2025-08-21 16:05:29 +02:00
49923adcd2 add 2025-08-21 15:29:34 +02:00
d5f8de1e4c add Keycloak, add better canvas 2025-08-21 15:26:35 +02:00
22 changed files with 2686 additions and 214 deletions

38
.replit
View File

@@ -1,42 +1,12 @@
modules = ["nodejs-20", "web", "postgresql-16"]
modules = ["nodejs-20", "web"]
run = "npm run dev"
hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
[nix]
channel = "stable-24_05"
channel = "stable-25_05"
[deployment]
deploymentTarget = "autoscale"
build = ["npm", "run", "build"]
run = ["npm", "run", "start"]
run = ["sh", "-c", "npm run dev"]
[[ports]]
localPort = 5000
localPort = 5001
externalPort = 80
[env]
PORT = "5000"
[workflows]
runButton = "Project"
[[workflows.workflow]]
name = "Project"
mode = "parallel"
author = "agent"
[[workflows.workflow.tasks]]
task = "workflow.run"
args = "Start application"
[[workflows.workflow]]
name = "Start application"
author = "agent"
[[workflows.workflow.tasks]]
task = "shell.exec"
args = "npm run dev"
waitForPort = 5000
[agent]
integrations = ["javascript_mem_db==1.0.0", "javascript_websocket==1.0.0"]

View File

@@ -51,6 +51,12 @@ EXPORT_PATH=./exports/ # Speicherort für SVG-Exports
ENABLE_AUTOMATIC_EVENTS=false # Automatische Events deaktiviert
EVENT_DURATION_MINUTES=30 # Event-Dauer
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)

96
KEYCLOAK_SETUP.md Normal file
View File

@@ -0,0 +1,96 @@
# 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

@@ -4,12 +4,14 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import CanvasPage from "@/pages/canvas";
import AdminPage from "@/pages/admin";
import NotFound from "@/pages/not-found";
function Router() {
return (
<Switch>
<Route path="/" component={CanvasPage} />
<Route path="/admin" component={AdminPage} />
<Route component={NotFound} />
</Switch>
);

View File

@@ -0,0 +1,58 @@
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,3 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Pixel } from "@shared/schema";
import { cn } from "@/lib/utils";
@@ -22,9 +23,11 @@ export function OptimizedCanvas({
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [pixelSize, setPixelSize] = useState(8);
const pixelSize = Math.max(2, 8 * zoom);
const [mouseCoords, setMouseCoords] = 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
const pixelMap = new Map<string, string>();
@@ -117,10 +120,6 @@ export function OptimizedCanvas({
drawCanvas();
}, [drawCanvas]);
useEffect(() => {
setPixelSize(Math.max(2, 8 * zoom));
}, [zoom]);
const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return null;
@@ -158,18 +157,114 @@ export function OptimizedCanvas({
const handleCanvasMouseLeave = () => {
setMouseCoords(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 = () => {
setZoom(prev => Math.min(prev * 1.2, 3));
zoomToPoint(zoom * 1.2);
};
const handleZoomOut = () => {
setZoom(prev => Math.max(prev / 1.2, 0.5));
zoomToPoint(zoom / 1.2);
};
const handleResetZoom = () => {
setZoom(1);
zoomToPoint(1);
};
const handleWheel = (e: React.WheelEvent) => {
@@ -178,23 +273,39 @@ export function OptimizedCanvas({
const zoomFactor = 1.1;
const delta = e.deltaY;
let newZoom;
if (delta < 0) {
setZoom(prev => Math.min(prev * zoomFactor, 3));
newZoom = zoom * zoomFactor;
} else {
setZoom(prev => Math.max(prev / zoomFactor, 0.5));
newZoom = zoom / zoomFactor;
}
zoomToPoint(newZoom, e.clientX, e.clientY);
};
return (
<div className="flex-1 relative bg-canvas-bg overflow-hidden">
<div
ref={containerRef}
className="w-full h-full overflow-auto p-8 scroll-smooth canvas-container"
className={cn(
"w-full h-full overflow-auto p-8 canvas-container",
isPanning && "cursor-grabbing select-none"
)}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMoveContainer}
onMouseLeave={() => {
setIsPanning(false);
setLastPanPosition(null);
}}
data-testid="canvas-container"
style={{
scrollBehavior: isPanning ? 'auto' : 'auto'
}}
>
{/* Coordinate System Container */}
<div className="relative inline-block canvas-zoom">
<div className="relative inline-block">
{/* Top X-axis coordinates */}
<div className="flex ml-8 mb-1">
{Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => (
@@ -203,7 +314,7 @@ export function OptimizedCanvas({
className="text-xs text-gray-400 text-center"
style={{
width: `${10 * pixelSize}px`,
fontSize: `${Math.max(8, pixelSize * 0.8)}px`
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px`
}}
>
{i * 10}
@@ -222,7 +333,7 @@ export function OptimizedCanvas({
style={{
height: `${10 * pixelSize}px`,
width: '24px',
fontSize: `${Math.max(8, pixelSize * 0.8)}px`
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px`
}}
>
{i * 10}
@@ -234,12 +345,13 @@ export function OptimizedCanvas({
<canvas
ref={canvasRef}
className={cn(
"border border-gray-400 cursor-pointer",
cooldownActive && "cursor-not-allowed"
"border border-gray-400",
isPanning ? "cursor-grabbing" : cooldownActive ? "cursor-not-allowed" : "cursor-pointer"
)}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
onContextMenu={(e) => e.preventDefault()} // Verhindert Rechtsklick-Menü
data-testid="pixel-canvas"
/>
</div>
@@ -247,48 +359,63 @@ export function OptimizedCanvas({
</div>
{/* Zoom Controls */}
<div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/80 p-2 rounded shadow">
<div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/90 p-3 rounded-lg shadow-lg">
<button
onClick={handleZoomIn}
className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors"
data-testid="button-zoom-in"
disabled={zoom >= 5}
>
+
</button>
<button
onClick={handleZoomOut}
className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors"
data-testid="button-zoom-out"
disabled={zoom <= 0.1}
>
-
</button>
<button
onClick={handleResetZoom}
className="px-2 py-1 bg-gray-500 text-white rounded text-xs hover:bg-gray-600"
className="px-2 py-1 bg-gray-500 text-white rounded text-xs font-semibold hover:bg-gray-600 transition-colors"
data-testid="button-zoom-reset"
>
100%
</button>
<div className="text-xs text-gray-600 text-center font-mono">
{Math.round(zoom * 100)}%
</div>
</div>
{/* Info Display */}
<div className="absolute bottom-4 left-4 bg-white/80 p-3 rounded shadow text-sm">
<div className="text-xs text-gray-600">
Canvas: {canvasWidth}x{canvasHeight}
<div className="absolute bottom-4 left-4 bg-white/90 p-3 rounded-lg shadow-lg text-sm">
<div className="text-xs text-gray-600 font-semibold">
Canvas: {canvasWidth}×{canvasHeight}
</div>
<div className="text-xs text-gray-400">
Zoom: {Math.round(zoom * 100)}%
<div className="text-xs text-gray-500 mt-1">
Pixel: {pixelSize}px | Zoom: {Math.round(zoom * 100)}%
</div>
{mouseCoords && (
<div className="text-xs text-green-400 mt-1">
Mouse: ({mouseCoords.x}, {mouseCoords.y})
<div className="text-xs text-green-600 mt-2 font-mono">
Position: ({mouseCoords.x}, {mouseCoords.y})
</div>
)}
{previewPixel && !cooldownActive && (
<div className="text-xs text-blue-400 mt-1">
<div className="text-xs text-blue-600 mt-1">
Vorschau: {selectedColor}
</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>
);

View File

@@ -131,31 +131,49 @@
100% { background-position: 20px 20px; }
}
/* 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 optimiert für glattes Zoomen */
.canvas-container {
scroll-behavior: smooth;
scroll-behavior: auto; /* Für präzises Zoom-Verhalten */
}
/* Optimierte Pixel-Hover-Effekte */
.pixel {
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out, opacity 0.1s ease-out;
.canvas-container:not(:hover) {
scroll-behavior: smooth; /* Smooth nur wenn nicht gehovered */
}
.pixel:hover {
transform: scale(1.1);
z-index: 10;
position: relative;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
/* Zoom Controls */
.canvas-container * {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
/* 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 */

View File

@@ -0,0 +1,12 @@
import { apiRequest } from "./queryClient";
export interface ExpandCanvasRequest {
canvasWidth: number;
canvasHeight: number;
}
export const expandCanvas = async (dimensions: ExpandCanvasRequest) => {
const response = await apiRequest("POST", "/api/config/expand", dimensions);
return response.json();
};

View File

@@ -29,7 +29,7 @@ export const COLORS = [
"#ffb470", // Beige
"#000000", // Black
"#515252", // Dark Gray
"#898d90", // Gray
"#898989", // Gray
"#d4d7d9", // Light Gray
"#ffffff", // White
] as const;
@@ -48,3 +48,24 @@ export function generateUserId(): string {
export function getUsername(): string {
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 };
}
}

382
client/src/pages/admin.tsx Normal file
View File

@@ -0,0 +1,382 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { Pixel, CanvasConfig } from "@shared/schema";
import { apiRequest } from "@/lib/queryClient";
import { AuthBanner } from "@/components/auth-banner";
export default function AdminPage() {
const [adminKey, setAdminKey] = useState("");
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [selectedPixelId, setSelectedPixelId] = useState("");
const [canvasWidth, setCanvasWidth] = useState(500);
const [canvasHeight, setCanvasHeight] = useState(200);
const { toast } = useToast();
// Auth check
const checkAuth = async (key: string) => {
try {
const response = await apiRequest("POST", "/api/admin/auth", { adminKey: key });
if (response.ok) {
setIsAuthenticated(true);
localStorage.setItem("adminKey", key);
toast({ title: "Admin-Zugang gewährt" });
} else {
throw new Error("Invalid key");
}
} catch (error) {
toast({
title: "Ungültiger Admin-Schlüssel",
variant: "destructive"
});
}
};
// Check saved auth on load
useEffect(() => {
const savedKey = localStorage.getItem("adminKey");
if (savedKey) {
setAdminKey(savedKey);
checkAuth(savedKey);
}
}, []);
// Fetch data
const { data: pixels = [], refetch: refetchPixels } = useQuery<Pixel[]>({
queryKey: ['/api/pixels'],
enabled: isAuthenticated,
});
const { data: config } = useQuery<CanvasConfig>({
queryKey: ['/api/config'],
enabled: isAuthenticated,
});
const { data: adminStats } = useQuery({
queryKey: ['/api/admin/stats'],
enabled: isAuthenticated,
queryFn: async () => {
const response = await apiRequest("GET", "/api/admin/stats", undefined, {
'X-Admin-Key': adminKey
});
return response.json();
},
});
// Mutations
const deletePixelMutation = useMutation({
mutationFn: async (pixelId: string) => {
const response = await apiRequest("DELETE", `/api/admin/pixels/${pixelId}`, undefined, {
'X-Admin-Key': adminKey
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
queryClient.invalidateQueries({ queryKey: ['/api/admin/stats'] });
toast({ title: "Pixel gelöscht" });
},
});
const clearCanvasMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest("DELETE", "/api/admin/canvas", undefined, {
'X-Admin-Key': adminKey
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
queryClient.invalidateQueries({ queryKey: ['/api/admin/stats'] });
toast({ title: "Canvas geleert" });
},
});
const expandCanvasMutation = useMutation({
mutationFn: async ({ width, height }: { width: number; height: number }) => {
const response = await apiRequest("POST", "/api/config/expand", {
canvasWidth: width,
canvasHeight: height
}, {
'X-Admin-Key': adminKey
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/config'] });
toast({ title: "Canvas erweitert" });
},
});
const exportCanvasMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest("POST", "/api/admin/export", undefined, {
'X-Admin-Key': adminKey
});
return response.json();
},
onSuccess: (data) => {
toast({ title: "Canvas exportiert", description: `Datei: ${data.filename}` });
},
});
if (!isAuthenticated) {
return (
<div className="min-h-screen bg-canvas-bg text-white flex items-center justify-center">
<Card className="w-96 bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">Admin-Zugang</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
type="password"
placeholder="Admin-Schlüssel eingeben"
value={adminKey}
onChange={(e) => setAdminKey(e.target.value)}
className="bg-panel-hover border-gray-600 text-white"
/>
<Button
onClick={() => checkAuth(adminKey)}
className="w-full"
disabled={!adminKey}
>
Anmelden
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-canvas-bg text-white">
<AuthBanner />
<div className="container mx-auto p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Admin-Panel</h1>
<Button
variant="outline"
onClick={() => {
setIsAuthenticated(false);
localStorage.removeItem("adminKey");
}}
>
Abmelden
</Button>
</div>
<Tabs defaultValue="overview" className="space-y-6">
<TabsList className="bg-panel-bg">
<TabsTrigger value="overview">Übersicht</TabsTrigger>
<TabsTrigger value="moderation">Moderation</TabsTrigger>
<TabsTrigger value="canvas">Canvas-Verwaltung</TabsTrigger>
<TabsTrigger value="export">Export</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">Statistiken</CardTitle>
</CardHeader>
<CardContent className="text-white">
<div className="space-y-2">
<div className="flex justify-between">
<span>Gesamte Pixel:</span>
<Badge>{adminStats?.totalPixels || 0}</Badge>
</div>
<div className="flex justify-between">
<span>Einzigartige Nutzer:</span>
<Badge>{adminStats?.uniqueUsers || 0}</Badge>
</div>
<div className="flex justify-between">
<span>Canvas-Größe:</span>
<Badge>{config?.canvasWidth}×{config?.canvasHeight}</Badge>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">Letzte Aktivität</CardTitle>
</CardHeader>
<CardContent className="text-white">
<div className="space-y-2 max-h-40 overflow-y-auto">
{pixels.slice(0, 5).map((pixel) => (
<div key={pixel.id} className="text-sm">
<span className="text-muted">{pixel.username}</span> -
<span className="ml-1">({pixel.x}, {pixel.y})</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">System</CardTitle>
</CardHeader>
<CardContent className="text-white">
<div className="space-y-2">
<div className="flex justify-between">
<span>Cooldown:</span>
<Badge>{config?.defaultCooldown}s</Badge>
</div>
<div className="flex justify-between">
<span>Auto-Events:</span>
<Badge variant={config?.enableAutomaticEvents ? "default" : "secondary"}>
{config?.enableAutomaticEvents ? "An" : "Aus"}
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="moderation">
<div className="space-y-6">
<Card className="bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">Pixel-Moderation</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex space-x-2">
<Input
placeholder="Pixel-ID eingeben"
value={selectedPixelId}
onChange={(e) => setSelectedPixelId(e.target.value)}
className="bg-panel-hover border-gray-600 text-white"
/>
<Button
onClick={() => deletePixelMutation.mutate(selectedPixelId)}
disabled={!selectedPixelId}
variant="destructive"
>
Pixel löschen
</Button>
</div>
<div className="border-t border-gray-600 pt-4">
<Button
onClick={() => clearCanvasMutation.mutate()}
variant="destructive"
className="w-full"
>
Gesamtes Canvas leeren
</Button>
</div>
</CardContent>
</Card>
<Card className="bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">Pixel-Liste</CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-60 overflow-y-auto space-y-2">
{pixels.slice().reverse().slice(0, 20).map((pixel) => (
<div key={pixel.id} className="flex items-center justify-between p-2 bg-panel-hover rounded">
<div className="text-white text-sm">
<span className="font-mono">{pixel.id.slice(0, 8)}</span> -
<span className="ml-2">{pixel.username}</span> -
<span className="ml-2">({pixel.x}, {pixel.y})</span> -
<span
className="ml-2 inline-block w-4 h-4 rounded"
style={{ backgroundColor: pixel.color }}
/>
</div>
<Button
size="sm"
variant="destructive"
onClick={() => deletePixelMutation.mutate(pixel.id)}
>
Löschen
</Button>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="canvas">
<Card className="bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">Canvas-Verwaltung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-white text-sm">Breite</label>
<Input
type="number"
value={canvasWidth}
onChange={(e) => setCanvasWidth(Number(e.target.value))}
className="bg-panel-hover border-gray-600 text-white"
/>
</div>
<div>
<label className="text-white text-sm">Höhe</label>
<Input
type="number"
value={canvasHeight}
onChange={(e) => setCanvasHeight(Number(e.target.value))}
className="bg-panel-hover border-gray-600 text-white"
/>
</div>
</div>
<Button
onClick={() => expandCanvasMutation.mutate({
width: canvasWidth,
height: canvasHeight
})}
className="w-full"
>
Canvas erweitern
</Button>
<div className="text-sm text-muted">
Aktuelle Größe: {config?.canvasWidth}×{config?.canvasHeight}
<br />
Hinweis: Canvas kann nur erweitert, nicht verkleinert werden.
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="export">
<Card className="bg-panel-bg border-gray-700">
<CardHeader>
<CardTitle className="text-white">Export-Verwaltung</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={() => exportCanvasMutation.mutate()}
className="w-full"
>
Manueller SVG-Export
</Button>
<div className="text-sm text-muted">
Automatische Exports erfolgen alle {config?.exportInterval || 60} Sekunden.
Dateien werden im exports/ Verzeichnis gespeichert.
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { useToast } from "@/hooks/use-toast";
import { DEFAULT_SELECTED_COLOR, generateUserId, getUsername } from "@/lib/config";
import { Pixel, CanvasConfig, InsertPixel, WSMessage } from "@shared/schema";
import { apiRequest } from "@/lib/queryClient";
import { AuthBanner } from "@/components/auth-banner";
export default function CanvasPage() {
const [selectedColor, setSelectedColor] = useState(DEFAULT_SELECTED_COLOR);
@@ -42,13 +43,15 @@ export default function CanvasPage() {
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
//toast({
// title: "Pixel placed",
// description: `${message.data.username} placed a pixel at (${message.data.x}, ${message.data.y})`,
// });
//break;
// Aktualisiere Cooldown nach jedem Pixel-Placement (auch von anderen Nutzern)
// um sicherzustellen, dass lokaler State mit Server synchron ist
if (userId) {
fetch(`/api/cooldown/${userId}`)
.then(res => res.json())
.then(data => setCooldownSeconds(data.remainingSeconds))
.catch(error => console.error("Failed to sync cooldown:", error));
}
break;
case "cooldown_update":
if (message.data.userId === userId) {
@@ -66,13 +69,22 @@ export default function CanvasPage() {
const response = await apiRequest("POST", "/api/pixels", pixel);
return response.json();
},
onSuccess: () => {
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: ['/api/pixels'] });
queryClient.invalidateQueries({ queryKey: ['/api/recent'] });
// Start cooldown countdown
// Hole den aktuellen Cooldown-Status vom Server
try {
const cooldownResponse = await fetch(`/api/cooldown/${userId}`);
const cooldownData = await cooldownResponse.json();
setCooldownSeconds(cooldownData.remainingSeconds);
} catch (error) {
console.error("Failed to fetch cooldown after placement:", error);
// Fallback: Setze Standard-Cooldown wenn Server-Abfrage fehlschlägt
if (config && !config.enableAutomaticEvents) {
setCooldownSeconds(config.defaultCooldown);
}
}
},
onError: (error: any) => {
if (error.message.includes("429")) {
@@ -117,10 +129,14 @@ export default function CanvasPage() {
}
};
if (userId) {
if (userId && config) {
fetchCooldown();
// Aktualisiere Cooldown regelmäßig für den Fall, dass er anderswo gesetzt wurde
const interval = setInterval(fetchCooldown, 2000);
return () => clearInterval(interval);
}
}, [userId]);
}, [userId, config]);
const handlePixelClick = (x: number, y: number) => {
if (cooldownSeconds > 0) return;
@@ -146,6 +162,7 @@ export default function CanvasPage() {
return (
<div className="h-screen flex flex-col bg-canvas-bg text-white">
<AuthBanner />
{/* Header */}
<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">
@@ -158,9 +175,14 @@ export default function CanvasPage() {
</div>
<div className="flex items-center space-x-3">
{/* Admin Link */}
<Button
variant="outline"
size="sm"
onClick={() => window.location.href = '/admin'}
>
Admin
</Button>
{/* User Info */}
<div className="flex items-center space-x-2 px-3 py-2 bg-panel-hover rounded-lg">

View File

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

1245
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,8 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -51,9 +53,10 @@
"drizzle-zod": "^0.7.0",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"express-session": "^1.18.1",
"express-session": "^1.18.2",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.2",
"keycloak-connect": "^26.1.1",
"lucide-react": "^0.453.0",
"memorystore": "^1.6.7",
"nanoid": "^5.1.5",

View File

@@ -11,6 +11,12 @@ interface Config {
autoExportIntervalSeconds: number;
exportPath: string;
adminKey: string;
enableKeycloak: boolean;
keycloakRealm: string;
keycloakAuthUrl: string;
keycloakClientId: string;
}
function parseConfigFile(): Config {
@@ -18,69 +24,48 @@ function parseConfigFile(): Config {
const configPath = join(process.cwd(), "config.cfg");
const configContent = readFileSync(configPath, "utf-8");
const config: Partial<Config> = {};
const configMap = new Map<string, string>();
configContent.split("\n").forEach(line => {
line = line.trim();
if (line.startsWith("#") || !line.includes("=")) return;
const [key, value] = line.split("=");
const trimmedKey = key.trim();
const trimmedValue = value.trim();
switch (trimmedKey) {
case "CANVAS_WIDTH":
config.canvasWidth = parseInt(trimmedValue);
break;
case "CANVAS_HEIGHT":
config.canvasHeight = parseInt(trimmedValue);
break;
case "DEFAULT_COOLDOWN":
config.defaultCooldown = parseInt(trimmedValue);
break;
case "ENABLE_AUTOMATIC_EVENTS":
config.enableAutomaticEvents = trimmedValue.toLowerCase() === "true";
break;
case "EVENT_DURATION_MINUTES":
config.eventDurationMinutes = parseInt(trimmedValue);
break;
case "EVENT_INTERVAL_HOURS":
config.eventIntervalHours = parseInt(trimmedValue);
break;
case "AUTO_EXPORT_INTERVAL_SECONDS":
config.autoExportIntervalSeconds = parseInt(trimmedValue);
break;
case "EXPORT_PATH":
config.exportPath = trimmedValue;
break;
}
configMap.set(key.trim(), value.trim());
});
// Set defaults for missing values
return {
canvasWidth: config.canvasWidth || 100,
canvasHeight: config.canvasHeight || 100,
defaultCooldown: config.defaultCooldown || 5,
enableAutomaticEvents: config.enableAutomaticEvents || false,
eventDurationMinutes: config.eventDurationMinutes || 30,
eventIntervalHours: config.eventIntervalHours || 6,
autoExportIntervalSeconds: config.autoExportIntervalSeconds || 60,
exportPath: config.exportPath || "./exports/",
return {
canvasWidth: parseInt(configMap.get('CANVAS_WIDTH') || '500'),
canvasHeight: parseInt(configMap.get('CANVAS_HEIGHT') || '200'),
defaultCooldown: parseInt(configMap.get('DEFAULT_COOLDOWN') || '10'),
enableAutomaticEvents: configMap.get('ENABLE_AUTOMATIC_EVENTS') === 'true',
eventDurationMinutes: parseInt(configMap.get('EVENT_DURATION_MINUTES') || '30'),
eventIntervalHours: parseInt(configMap.get('EVENT_INTERVAL_HOURS') || '1'),
autoExportIntervalSeconds: parseInt(configMap.get('AUTO_EXPORT_INTERVAL_SECONDS') || '60'),
exportPath: configMap.get('EXPORT_PATH') || './exports/',
adminKey: configMap.get('ADMIN_KEY') || 'admin123',
enableKeycloak: configMap.get('ENABLE_KEYCLOAK') === 'true',
keycloakRealm: configMap.get('KEYCLOAK_REALM') || 'rplace',
keycloakAuthUrl: configMap.get('KEYCLOAK_AUTH_URL') || 'http://localhost:8080/auth',
keycloakClientId: configMap.get('KEYCLOAK_CLIENT_ID') || 'rplace-client',
};
} catch (error) {
console.error("Error reading config file, using defaults:", error);
return {
canvasWidth: 100,
canvasHeight: 100,
defaultCooldown: 5,
canvasWidth: 500,
canvasHeight: 200,
defaultCooldown: 10,
enableAutomaticEvents: false,
eventDurationMinutes: 30,
eventIntervalHours: 6,
eventIntervalHours: 1,
autoExportIntervalSeconds: 60,
exportPath: "./exports/",
adminKey: 'admin123',
enableKeycloak: false,
keycloakRealm: "rplace",
keycloakAuthUrl: "http://localhost:8080/auth",
keycloakClientId: "rplace-client",
};
}
}

View File

@@ -1,11 +1,29 @@
import express, { type Request, Response, NextFunction } from "express";
import { registerRoutes } from "./routes";
import { setupVite, serveStatic, log } from "./vite";
import { setupKeycloak } from "./keycloak";
import { config } from "./config";
import { storage } from "./storage";
import { CanvasExporter } from "./export";
const app = express();
app.use(express.json());
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) => {
const start = Date.now();
const path = req.path;
@@ -56,11 +74,47 @@ app.use((req, res, next) => {
serveStatic(app);
}
// Aktualisiere Canvas-Konfiguration beim Start falls sich config.cfg geändert hat
try {
const currentConfig = await storage.getCanvasConfig();
const configChanged =
currentConfig.canvasWidth !== config.canvasWidth ||
currentConfig.canvasHeight !== config.canvasHeight ||
currentConfig.defaultCooldown !== config.defaultCooldown ||
currentConfig.enableAutomaticEvents !== config.enableAutomaticEvents ||
currentConfig.eventDuration !== config.eventDurationMinutes ||
currentConfig.eventInterval !== config.eventIntervalHours;
if (configChanged) {
console.log(`${formatTime()} [express] Aktualisiere Canvas-Konfiguration aus config.cfg`);
// Für SQLite Storage: Verwende expandCanvas wenn Canvas vergrößert wird
if ('expandCanvas' in storage && typeof storage.expandCanvas === 'function' &&
(config.canvasWidth > currentConfig.canvasWidth || config.canvasHeight > currentConfig.canvasHeight)) {
await (storage as any).expandCanvas(config.canvasWidth, config.canvasHeight);
} else {
await storage.updateCanvasConfig({
canvasWidth: Math.max(currentConfig.canvasWidth, config.canvasWidth), // Erlaube nur Erweiterung
canvasHeight: Math.max(currentConfig.canvasHeight, config.canvasHeight), // Erlaube nur Erweiterung
defaultCooldown: config.defaultCooldown,
enableAutomaticEvents: config.enableAutomaticEvents,
eventDuration: config.eventDurationMinutes,
eventInterval: config.eventIntervalHours
});
}
console.log(`${formatTime()} [express] Canvas-Konfiguration aktualisiert`);
}
} catch (error) {
console.error(`${formatTime()} [express] Fehler beim Aktualisieren der Canvas-Konfiguration:`, error);
}
// Canvas exporter wird bereits in routes.ts initialisiert
// ALWAYS serve the app on the port specified in the environment variable PORT
// Other ports are firewalled. Default to 5000 if not specified.
// this serves both the API and the client.
// It is the only port that is not firewalled.
const port = parseInt(process.env.PORT || '5000', 10);
const port = parseInt(process.env.PORT || '5001', 10);
server.listen({
port,
host: "0.0.0.0",
@@ -69,3 +123,11 @@ app.use((req, res, next) => {
log(`serving on port ${port}`);
});
})();
function formatTime() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `[${hours}:${minutes}:${seconds}]`;
}

56
server/keycloak.ts Normal file
View File

@@ -0,0 +1,56 @@
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,9 +1,53 @@
import type { Express } from "express";
import type { Express, Request, Response, NextFunction } from "express";
import { createServer, type Server } from "http";
import { WebSocketServer, WebSocket } from "ws";
import { storage } from "./storage";
import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@shared/schema";
import { CanvasExporter } from "./export";
import { config } from "./config";
// Admin authentication middleware
function requireAdmin(req: Request, res: Response, next: NextFunction) {
const adminKey = req.headers['x-admin-key'] || req.body.adminKey;
if (!adminKey || adminKey !== config.adminKey) {
return res.status(401).json({ message: "Admin access required" });
}
next();
}
// 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> {
const httpServer = createServer(app);
@@ -15,6 +59,38 @@ export async function registerRoutes(app: Express): Promise<Server> {
const exporter = new CanvasExporter(storage);
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
app.get("/api/pixels", async (req, res) => {
try {
@@ -34,12 +110,59 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Canvas-Erweiterungs-Endpoint
app.post("/api/config/expand", async (req, res) => {
try {
const { canvasWidth, canvasHeight } = req.body;
if (!canvasWidth || !canvasHeight || canvasWidth < 1 || canvasHeight < 1) {
return res.status(400).json({ message: "Invalid canvas dimensions" });
}
const currentConfig = await storage.getCanvasConfig();
// Erlaube nur Erweiterung, nicht Verkleinerung
if (canvasWidth < currentConfig.canvasWidth || canvasHeight < currentConfig.canvasHeight) {
return res.status(400).json({
message: "Canvas kann nur erweitert werden, nicht verkleinert",
current: { width: currentConfig.canvasWidth, height: currentConfig.canvasHeight }
});
}
// Für SQLite Storage: Verwende spezielle expandCanvas Methode
if ('expandCanvas' in storage && typeof storage.expandCanvas === 'function') {
await (storage as any).expandCanvas(canvasWidth, canvasHeight);
} else {
// Fallback für Memory Storage
await storage.updateCanvasConfig({
canvasWidth,
canvasHeight,
defaultCooldown: currentConfig.defaultCooldown,
enableAutomaticEvents: currentConfig.enableAutomaticEvents,
eventDuration: currentConfig.eventDuration,
eventInterval: currentConfig.eventInterval
});
}
const updatedConfig = await storage.getCanvasConfig();
res.json(updatedConfig);
} catch (error) {
console.error("Failed to expand canvas:", error);
res.status(500).json({ message: "Failed to expand canvas" });
}
});
// Config is now read-only from file
// Remove the POST endpoint for config updates
app.post("/api/pixels", async (req, res) => {
app.post("/api/pixels", requireAuth, async (req, res) => {
try {
const pixelData = insertPixelSchema.parse(req.body);
const userInfo = getUserFromToken(req);
const pixelData = insertPixelSchema.parse({
...req.body,
userId: userInfo.userId,
username: userInfo.username
});
const config = await storage.getCanvasConfig();
// Validate coordinates
@@ -50,15 +173,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check cooldown unless events are enabled
if (!config.enableAutomaticEvents) {
const cooldown = await storage.getUserCooldown(pixelData.userId);
if (cooldown && cooldown.cooldownEnds > new Date()) {
return res.status(429).json({ message: "Cooldown active" });
const cooldown = await storage.getUserCooldown(userInfo.userId);
const now = new Date();
if (cooldown && cooldown.cooldownEnds > now) {
const remaining = Math.ceil((cooldown.cooldownEnds.getTime() - now.getTime()) / 1000);
return res.status(429).json({
message: "Cooldown active",
remainingSeconds: remaining
});
}
// Set new cooldown
// Set new cooldown - immer setzen, auch wenn kein vorheriger existierte
const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000));
await storage.setUserCooldown({
userId: pixelData.userId,
userId: userInfo.userId,
cooldownEnds: cooldownEnd,
});
}
@@ -156,6 +285,83 @@ export async function registerRoutes(app: Express): Promise<Server> {
});
});
// Admin Routes
app.post("/api/admin/auth", (req, res) => {
const { adminKey } = req.body;
if (adminKey === config.adminKey) {
res.json({ success: true });
} else {
res.status(401).json({ message: "Invalid admin key" });
}
});
app.get("/api/admin/stats", requireAdmin, async (req, res) => {
try {
const pixels = await storage.getAllPixels();
const uniqueUsers = new Set(pixels.map(p => p.userId)).size;
res.json({
totalPixels: pixels.length,
uniqueUsers,
lastActivity: pixels.length > 0 ? pixels[pixels.length - 1].createdAt : null
});
} catch (error) {
res.status(500).json({ message: "Failed to fetch admin stats" });
}
});
app.delete("/api/admin/pixels/:id", requireAdmin, async (req, res) => {
try {
const { id } = req.params;
if ('deletePixel' in storage && typeof storage.deletePixel === 'function') {
await (storage as any).deletePixel(id);
// Broadcast pixel deletion
broadcast({
type: "pixel_deleted",
data: { pixelId: id },
});
res.json({ success: true });
} else {
res.status(501).json({ message: "Pixel deletion not supported by current storage" });
}
} catch (error) {
res.status(500).json({ message: "Failed to delete pixel" });
}
});
app.delete("/api/admin/canvas", requireAdmin, async (req, res) => {
try {
if ('clearCanvas' in storage && typeof storage.clearCanvas === 'function') {
await (storage as any).clearCanvas();
// Broadcast canvas clear
broadcast({
type: "canvas_cleared",
data: {},
});
res.json({ success: true });
} else {
res.status(501).json({ message: "Canvas clearing not supported by current storage" });
}
} catch (error) {
res.status(500).json({ message: "Failed to clear canvas" });
}
});
app.post("/api/admin/export", requireAdmin, async (req, res) => {
try {
const filename = await exporter.exportCanvas();
res.json({ filename, success: true });
} catch (error) {
res.status(500).json({ message: "Failed to export canvas" });
}
});
// Keep connections alive with ping/pong
const pingInterval = setInterval(() => {
connectedUsers.forEach(ws => {

220
server/sqlite-storage.ts Normal file
View File

@@ -0,0 +1,220 @@
import Database from 'better-sqlite3';
import { type Pixel, type InsertPixel, type CanvasConfig, type InsertCanvasConfig, type UserCooldown, type InsertUserCooldown } from "@shared/schema";
import { randomUUID } from "crypto";
import { config } from "./config";
import { IStorage } from "./storage";
export class SQLiteStorage implements IStorage {
private db: Database.Database;
constructor(dbPath: string = ':memory:') {
this.db = new Database(dbPath);
this.initTables();
this.initDefaultConfig();
}
private initTables() {
// Pixels table
this.db.exec(`
CREATE TABLE IF NOT EXISTS pixels (
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
x INTEGER NOT NULL,
y INTEGER NOT NULL,
color TEXT NOT NULL,
userId TEXT NOT NULL,
username TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Canvas config table
this.db.exec(`
CREATE TABLE IF NOT EXISTS canvas_config (
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
canvasWidth INTEGER NOT NULL DEFAULT 100,
canvasHeight INTEGER NOT NULL DEFAULT 100,
defaultCooldown INTEGER NOT NULL DEFAULT 5,
enableAutomaticEvents BOOLEAN NOT NULL DEFAULT 0,
eventDuration INTEGER NOT NULL DEFAULT 30,
eventInterval INTEGER NOT NULL DEFAULT 6,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// User cooldowns table
this.db.exec(`
CREATE TABLE IF NOT EXISTS user_cooldowns (
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
userId TEXT NOT NULL UNIQUE,
lastPlacement DATETIME DEFAULT CURRENT_TIMESTAMP,
cooldownEnds DATETIME NOT NULL
)
`);
// Create indexes
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pixels_xy ON pixels(x, y)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_pixels_created ON pixels(createdAt DESC)`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_cooldowns_user ON user_cooldowns(userId)`);
}
private initDefaultConfig() {
const existingConfig = this.db.prepare('SELECT * FROM canvas_config LIMIT 1').get();
if (!existingConfig) {
this.db.prepare(`
INSERT INTO canvas_config (canvasWidth, canvasHeight, defaultCooldown, enableAutomaticEvents, eventDuration, eventInterval)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
config.canvasWidth,
config.canvasHeight,
config.defaultCooldown,
config.enableAutomaticEvents ? 1 : 0,
config.eventDurationMinutes,
config.eventIntervalHours
);
}
}
async getPixel(x: number, y: number): Promise<Pixel | undefined> {
const row = this.db.prepare('SELECT * FROM pixels WHERE x = ? AND y = ? ORDER BY createdAt DESC LIMIT 1').get(x, y) as any;
if (!row) return undefined;
return {
...row,
createdAt: new Date(row.createdAt),
enableAutomaticEvents: Boolean(row.enableAutomaticEvents)
};
}
async getAllPixels(): Promise<Pixel[]> {
const rows = this.db.prepare(`
SELECT p1.* FROM pixels p1
INNER JOIN (
SELECT x, y, MAX(createdAt) as maxCreated
FROM pixels
GROUP BY x, y
) p2 ON p1.x = p2.x AND p1.y = p2.y AND p1.createdAt = p2.maxCreated
`).all() as any[];
return rows.map(row => ({
...row,
createdAt: new Date(row.createdAt)
}));
}
async placePixel(insertPixel: InsertPixel): Promise<Pixel> {
const id = randomUUID();
const now = new Date();
this.db.prepare(`
INSERT INTO pixels (id, x, y, color, userId, username, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(id, insertPixel.x, insertPixel.y, insertPixel.color, insertPixel.userId, insertPixel.username, now.toISOString());
return {
id,
...insertPixel,
createdAt: now
};
}
async getCanvasConfig(): Promise<CanvasConfig> {
const row = this.db.prepare('SELECT * FROM canvas_config ORDER BY updatedAt DESC LIMIT 1').get() as any;
return {
...row,
enableAutomaticEvents: Boolean(row.enableAutomaticEvents),
updatedAt: new Date(row.updatedAt)
};
}
async updateCanvasConfig(configUpdate: InsertCanvasConfig): Promise<CanvasConfig> {
const currentConfig = await this.getCanvasConfig();
const now = new Date();
this.db.prepare(`
UPDATE canvas_config
SET canvasWidth = ?, canvasHeight = ?, defaultCooldown = ?,
enableAutomaticEvents = ?, eventDuration = ?, eventInterval = ?,
updatedAt = ?
WHERE id = ?
`).run(
configUpdate.canvasWidth ?? currentConfig.canvasWidth,
configUpdate.canvasHeight ?? currentConfig.canvasHeight,
configUpdate.defaultCooldown ?? currentConfig.defaultCooldown,
configUpdate.enableAutomaticEvents ? 1 : 0,
configUpdate.eventDuration ?? currentConfig.eventDuration,
configUpdate.eventInterval ?? currentConfig.eventInterval,
now.toISOString(),
currentConfig.id
);
return this.getCanvasConfig();
}
async getUserCooldown(userId: string): Promise<UserCooldown | undefined> {
const row = this.db.prepare('SELECT * FROM user_cooldowns WHERE userId = ?').get(userId) as any;
if (!row) return undefined;
return {
...row,
lastPlacement: new Date(row.lastPlacement),
cooldownEnds: new Date(row.cooldownEnds)
};
}
async setUserCooldown(insertCooldown: InsertUserCooldown): Promise<UserCooldown> {
const id = randomUUID();
const now = new Date();
this.db.prepare(`
INSERT OR REPLACE INTO user_cooldowns (id, userId, lastPlacement, cooldownEnds)
VALUES (?, ?, ?, ?)
`).run(id, insertCooldown.userId, now.toISOString(), insertCooldown.cooldownEnds.toISOString());
return {
id,
userId: insertCooldown.userId,
lastPlacement: now,
cooldownEnds: insertCooldown.cooldownEnds
};
}
async getRecentPlacements(limit: number = 10): Promise<Pixel[]> {
const rows = this.db.prepare(`
SELECT * FROM pixels
ORDER BY createdAt DESC
LIMIT ?
`).all(limit) as any[];
return rows.map(row => ({
...row,
createdAt: new Date(row.createdAt)
}));
}
// Canvas-Erweiterungsmethode
async expandCanvas(newWidth: number, newHeight: number): Promise<void> {
const currentConfig = await this.getCanvasConfig();
if (newWidth < currentConfig.canvasWidth || newHeight < currentConfig.canvasHeight) {
throw new Error("Canvas kann nur erweitert werden, nicht verkleinert");
}
await this.updateCanvasConfig({
canvasWidth: newWidth,
canvasHeight: newHeight,
defaultCooldown: currentConfig.defaultCooldown,
enableAutomaticEvents: currentConfig.enableAutomaticEvents,
eventDuration: currentConfig.eventDuration,
eventInterval: currentConfig.eventInterval
});
}
async deletePixel(pixelId: string): Promise<void> {
this.db.prepare("DELETE FROM pixels WHERE id = ?").run(pixelId);
}
async clearCanvas(): Promise<void> {
this.db.prepare("DELETE FROM pixels").run();
}
}

View File

@@ -18,6 +18,10 @@ export interface IStorage {
// Recent activity
getRecentPlacements(limit?: number): Promise<Pixel[]>;
// Admin operations
deletePixel(pixelId: string): Promise<void>;
clearCanvas(): Promise<void>;
}
export class MemStorage implements IStorage {
@@ -100,6 +104,21 @@ export class MemStorage implements IStorage {
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
}
async deletePixel(pixelId: string): Promise<void> {
this.pixels.delete(pixelId);
}
export const storage = new MemStorage();
async clearCanvas(): Promise<void> {
this.pixels.clear();
}
}
// The SQLiteStorage import and usage below is not part of the changes,
// but is included to ensure the file is complete as per instructions.
import { SQLiteStorage } from "./sqlite-storage";
// Verwende SQLite im Development-Modus, Memory-Storage in Production
export const storage = process.env.NODE_ENV === 'development'
? new SQLiteStorage('./dev-database.sqlite')
: new MemStorage();

21
server/types/keycloak.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
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;
};
}
}

View File

@@ -83,4 +83,9 @@ export const wsMessageSchema = z.union([
}),
]);
export type WSMessage = z.infer<typeof wsMessageSchema>;
export type WSMessage =
| { type: "pixel_placed"; data: { x: number; y: number; color: string; userId: string; username: string; timestamp: string } }
| { type: "user_count"; data: { count: number } }
| { type: "cooldown_update"; data: { userId: string; remainingSeconds: number } }
| { type: "pixel_deleted"; data: { pixelId: string } }
| { type: "canvas_cleared"; data: {} };