coldown fix

This commit is contained in:
2025-08-21 16:05:29 +02:00
parent 49923adcd2
commit 68eeaa063d
13 changed files with 1287 additions and 133 deletions

View File

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

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

@@ -7,17 +7,21 @@ export interface IStorage {
getPixel(x: number, y: number): Promise<Pixel | undefined>;
getAllPixels(): Promise<Pixel[]>;
placePixel(pixel: InsertPixel): Promise<Pixel>;
// Config operations
getCanvasConfig(): Promise<CanvasConfig>;
updateCanvasConfig(config: InsertCanvasConfig): Promise<CanvasConfig>;
// User cooldown operations
getUserCooldown(userId: string): Promise<UserCooldown | undefined>;
setUserCooldown(cooldown: InsertUserCooldown): Promise<UserCooldown>;
// Recent activity
getRecentPlacements(limit?: number): Promise<Pixel[]>;
// Admin operations
deletePixel(pixelId: string): Promise<void>;
clearCanvas(): Promise<void>;
}
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<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();