From 68eeaa063d1acec77af035dad71b91ef21067452 Mon Sep 17 00:00:00 2001 From: MrEgosar Date: Thu, 21 Aug 2025 16:05:29 +0200 Subject: [PATCH] coldown fix --- .replit | 38 +--- client/src/App.tsx | 2 + client/src/lib/canvas-api.ts | 12 ++ client/src/pages/admin.tsx | 382 +++++++++++++++++++++++++++++++++ client/src/pages/canvas.tsx | 52 +++-- package-lock.json | 394 ++++++++++++++++++++++++++++++++++- package.json | 2 + server/config.ts | 90 +++----- server/index.ts | 48 ++++- server/routes.ts | 142 ++++++++++++- server/sqlite-storage.ts | 220 +++++++++++++++++++ server/storage.ts | 31 ++- shared/schema.ts | 7 +- 13 files changed, 1287 insertions(+), 133 deletions(-) create mode 100644 client/src/lib/canvas-api.ts create mode 100644 client/src/pages/admin.tsx create mode 100644 server/sqlite-storage.ts diff --git a/.replit b/.replit index 8962b7f..916350e 100644 --- a/.replit +++ b/.replit @@ -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"] diff --git a/client/src/App.tsx b/client/src/App.tsx index a253570..15d6aaa 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( + ); diff --git a/client/src/lib/canvas-api.ts b/client/src/lib/canvas-api.ts new file mode 100644 index 0000000..e5e73e3 --- /dev/null +++ b/client/src/lib/canvas-api.ts @@ -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(); +}; diff --git a/client/src/pages/admin.tsx b/client/src/pages/admin.tsx new file mode 100644 index 0000000..41b831c --- /dev/null +++ b/client/src/pages/admin.tsx @@ -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({ + queryKey: ['/api/pixels'], + enabled: isAuthenticated, + }); + + const { data: config } = useQuery({ + 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 ( +
+ + + Admin-Zugang + + + setAdminKey(e.target.value)} + className="bg-panel-hover border-gray-600 text-white" + /> + + + +
+ ); + } + + return ( +
+ +
+
+

Admin-Panel

+ +
+ + + + Übersicht + Moderation + Canvas-Verwaltung + Export + + + +
+ + + Statistiken + + +
+
+ Gesamte Pixel: + {adminStats?.totalPixels || 0} +
+
+ Einzigartige Nutzer: + {adminStats?.uniqueUsers || 0} +
+
+ Canvas-Größe: + {config?.canvasWidth}×{config?.canvasHeight} +
+
+
+
+ + + + Letzte Aktivität + + +
+ {pixels.slice(0, 5).map((pixel) => ( +
+ {pixel.username} - + ({pixel.x}, {pixel.y}) +
+ ))} +
+
+
+ + + + System + + +
+
+ Cooldown: + {config?.defaultCooldown}s +
+
+ Auto-Events: + + {config?.enableAutomaticEvents ? "An" : "Aus"} + +
+
+
+
+
+
+ + +
+ + + Pixel-Moderation + + +
+ setSelectedPixelId(e.target.value)} + className="bg-panel-hover border-gray-600 text-white" + /> + +
+ +
+ +
+
+
+ + + + Pixel-Liste + + +
+ {pixels.slice().reverse().slice(0, 20).map((pixel) => ( +
+
+ {pixel.id.slice(0, 8)} - + {pixel.username} - + ({pixel.x}, {pixel.y}) - + +
+ +
+ ))} +
+
+
+
+
+ + + + + Canvas-Verwaltung + + +
+
+ + setCanvasWidth(Number(e.target.value))} + className="bg-panel-hover border-gray-600 text-white" + /> +
+
+ + setCanvasHeight(Number(e.target.value))} + className="bg-panel-hover border-gray-600 text-white" + /> +
+
+ + + +
+ Aktuelle Größe: {config?.canvasWidth}×{config?.canvasHeight} +
+ Hinweis: Canvas kann nur erweitert, nicht verkleinert werden. +
+
+
+
+ + + + + Export-Verwaltung + + + + +
+ Automatische Exports erfolgen alle {config?.exportInterval || 60} Sekunden. + Dateien werden im exports/ Verzeichnis gespeichert. +
+
+
+
+
+
+
+ ); +} diff --git a/client/src/pages/canvas.tsx b/client/src/pages/canvas.tsx index ec05a72..4b449ab 100644 --- a/client/src/pages/canvas.tsx +++ b/client/src/pages/canvas.tsx @@ -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,12 +69,21 @@ 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 - if (config && !config.enableAutomaticEvents) { - setCooldownSeconds(config.defaultCooldown); + + // 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) => { @@ -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() {
- - - + {/* Admin Link */} + {/* User Info */}
diff --git a/package-lock.json b/package-lock.json index bd1d01a..026c370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 883b927..9e083da 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/config.ts b/server/config.ts index 06c5725..60264a6 100644 --- a/server/config.ts +++ b/server/config.ts @@ -11,6 +11,7 @@ interface Config { autoExportIntervalSeconds: number; exportPath: string; + adminKey: string; enableKeycloak: boolean; keycloakRealm: string; @@ -22,87 +23,44 @@ function parseConfigFile(): Config { try { const configPath = join(process.cwd(), "config.cfg"); const configContent = readFileSync(configPath, "utf-8"); - - const config: Partial = {}; - + + const configMap = new Map(); 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", diff --git a/server/index.ts b/server/index.ts index 687085f..0449d56 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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()); @@ -15,7 +17,7 @@ if (config.enableKeycloak) { 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 { @@ -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}]`; +} \ No newline at end of file diff --git a/server/routes.ts b/server/routes.ts index b8bc48b..8f3dc8d 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -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 { } }); + // 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 { // 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 { }); }); + // 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 => { diff --git a/server/sqlite-storage.ts b/server/sqlite-storage.ts new file mode 100644 index 0000000..2ab187b --- /dev/null +++ b/server/sqlite-storage.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + this.db.prepare("DELETE FROM pixels WHERE id = ?").run(pixelId); + } + + async clearCanvas(): Promise { + this.db.prepare("DELETE FROM pixels").run(); + } +} \ No newline at end of file diff --git a/server/storage.ts b/server/storage.ts index 4884f1a..a842311 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -7,17 +7,21 @@ export interface IStorage { getPixel(x: number, y: number): Promise; getAllPixels(): Promise; placePixel(pixel: InsertPixel): Promise; - + // Config operations getCanvasConfig(): Promise; updateCanvasConfig(config: InsertCanvasConfig): Promise; - + // User cooldown operations getUserCooldown(userId: string): Promise; setUserCooldown(cooldown: InsertUserCooldown): Promise; - + // Recent activity getRecentPlacements(limit?: number): Promise; + + // Admin operations + deletePixel(pixelId: string): Promise; + clearCanvas(): Promise; } export class MemStorage implements IStorage { @@ -60,7 +64,7 @@ export class MemStorage implements IStorage { id, createdAt: new Date(), }; - + this.pixels.set(this.getPixelKey(pixel.x, pixel.y), pixel); return pixel; } @@ -89,7 +93,7 @@ export class MemStorage implements IStorage { id, lastPlacement: new Date(), }; - + this.userCooldowns.set(cooldown.userId, cooldown); return cooldown; } @@ -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 { + this.pixels.delete(pixelId); + } + + async clearCanvas(): Promise { + 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(); \ No newline at end of file diff --git a/shared/schema.ts b/shared/schema.ts index d24f2f0..6e8b56b 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -83,4 +83,9 @@ export const wsMessageSchema = z.union([ }), ]); -export type WSMessage = z.infer; +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: {} }; \ No newline at end of file