coldown fix

This commit is contained in:
MrEgosar 2025-08-21 16:05:29 +02:00
parent 49923adcd2
commit 68eeaa063d
13 changed files with 1287 additions and 133 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

@ -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,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();
};

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

@ -43,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) {
@ -67,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")) {
@ -118,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;
@ -160,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">

394
package-lock.json generated
View File

@ -40,6 +40,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",
@ -3334,6 +3336,15 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@ -3823,6 +3834,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/basic-ftp": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
@ -3833,6 +3864,20 @@
"node": ">=10.0.0"
}
},
"node_modules/better-sqlite3": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz",
"integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -3845,6 +3890,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
@ -3950,6 +4015,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@ -4088,6 +4177,12 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/chromedriver": {
"version": "139.0.2",
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-139.0.2.tgz",
@ -4442,6 +4537,30 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -4514,7 +4633,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"dev": true,
"engines": {
"node": ">=8"
}
@ -5219,7 +5337,6 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"once": "^1.4.0"
}
@ -5424,6 +5541,15 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@ -5610,6 +5736,12 @@
"pend": "~1.2.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -5768,6 +5900,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -5890,6 +6028,12 @@
"node": ">= 14"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@ -6079,12 +6223,38 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@ -6739,6 +6909,18 @@
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@ -6766,6 +6948,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@ -6781,6 +6972,12 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/modern-screenshot": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.6.0.tgz",
@ -6832,6 +7029,12 @@
"node": "^18 || >=20"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -6860,6 +7063,30 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/node-abi": {
"version": "3.75.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
"integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
@ -6960,7 +7187,6 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
}
@ -7503,6 +7729,32 @@
"integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==",
"license": "MIT"
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -7579,7 +7831,6 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@ -7653,6 +7904,21 @@
"node": ">= 0.8"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -7844,6 +8110,20 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -8181,6 +8461,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@ -8270,6 +8595,15 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -8366,6 +8700,15 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
@ -8462,6 +8805,34 @@
"node": ">=6"
}
},
"node_modules/tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tcp-port-used": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tcp-port-used/-/tcp-port-used-1.0.2.tgz",
@ -9026,6 +9397,18 @@
"@esbuild/win32-x64": "0.23.1"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tw-animate-css": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz",
@ -9841,8 +10224,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",

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",

View File

@ -11,6 +11,7 @@ interface Config {
autoExportIntervalSeconds: number;
exportPath: string;
adminKey: string;
enableKeycloak: boolean;
keycloakRealm: string;
@ -23,86 +24,43 @@ 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;
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;
}
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/",
enableKeycloak: config.enableKeycloak || false,
keycloakRealm: config.keycloakRealm || "rplace",
keycloakAuthUrl: config.keycloakAuthUrl || "http://localhost:8080/auth",
keycloakClientId: config.keycloakClientId || "rplace-client",
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",

View File

@ -3,6 +3,8 @@ 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());
@ -72,6 +74,42 @@ 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.
@ -85,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}]`;
}

View File

@ -6,6 +6,17 @@ import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@sh
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) {
@ -99,6 +110,48 @@ 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
@ -121,11 +174,17 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Check cooldown unless events are enabled
if (!config.enableAutomaticEvents) {
const cooldown = await storage.getUserCooldown(userInfo.userId);
if (cooldown && cooldown.cooldownEnds > new Date()) {
return res.status(429).json({ message: "Cooldown active" });
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: userInfo.userId,
@ -226,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);
}
async clearCanvas(): Promise<void> {
this.pixels.clear();
}
}
export const storage = new MemStorage();
// 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();

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: {} };