376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
import type { Express, Request, Response, NextFunction } from "express";
|
|
import { createServer, type Server } from "http";
|
|
import { WebSocketServer, WebSocket } from "ws";
|
|
import { storage } from "./storage";
|
|
import { insertPixelSchema, insertUserCooldownSchema, type WSMessage } from "@shared/schema";
|
|
import { CanvasExporter } from "./export";
|
|
import { config } from "./config";
|
|
|
|
// Admin authentication middleware
|
|
function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
|
const adminKey = req.headers['x-admin-key'] || req.body.adminKey;
|
|
|
|
if (!adminKey || adminKey !== config.adminKey) {
|
|
return res.status(401).json({ message: "Admin access required" });
|
|
}
|
|
|
|
next();
|
|
}
|
|
|
|
// Authentication middleware
|
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
if (!config.enableKeycloak) {
|
|
return next();
|
|
}
|
|
|
|
// Check if user is authenticated via Keycloak
|
|
if (req.kauth && req.kauth.grant) {
|
|
return next();
|
|
}
|
|
|
|
return res.status(401).json({ message: "Authentication required" });
|
|
}
|
|
|
|
// Get user info from Keycloak token
|
|
function getUserFromToken(req: Request): { userId: string; username: string } {
|
|
if (!config.enableKeycloak || !req.kauth?.grant?.access_token) {
|
|
return {
|
|
userId: "User",
|
|
username: "Anonymous"
|
|
};
|
|
}
|
|
|
|
const token = req.kauth.grant.access_token;
|
|
const content = token.content;
|
|
|
|
return {
|
|
userId: content.sub || content.preferred_username || "User",
|
|
username: content.preferred_username || content.name || "User"
|
|
};
|
|
}
|
|
|
|
export async function registerRoutes(app: Express): Promise<Server> {
|
|
const httpServer = createServer(app);
|
|
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
|
|
|
let connectedUsers = new Set<WebSocket>();
|
|
|
|
// Initialize canvas exporter
|
|
const exporter = new CanvasExporter(storage);
|
|
exporter.startAutoExport();
|
|
|
|
// Authentication Routes
|
|
app.get("/api/auth/status", (req, res) => {
|
|
if (!config.enableKeycloak) {
|
|
return res.json({ authenticated: false, keycloakEnabled: false });
|
|
}
|
|
|
|
const isAuthenticated = req.kauth && req.kauth.grant;
|
|
const user = isAuthenticated ? getUserFromToken(req) : null;
|
|
|
|
res.json({
|
|
authenticated: isAuthenticated,
|
|
keycloakEnabled: true,
|
|
user: user
|
|
});
|
|
});
|
|
|
|
// Login redirect
|
|
app.get("/login", (req, res) => {
|
|
if (config.enableKeycloak && req.kauth) {
|
|
return req.kauth.login(req, res);
|
|
}
|
|
res.redirect("/");
|
|
});
|
|
|
|
// Logout
|
|
app.get("/logout", (req, res) => {
|
|
if (config.enableKeycloak && req.kauth) {
|
|
return req.kauth.logout(req, res);
|
|
}
|
|
res.redirect("/");
|
|
});
|
|
|
|
// API Routes
|
|
app.get("/api/pixels", async (req, res) => {
|
|
try {
|
|
const pixels = await storage.getAllPixels();
|
|
res.json(pixels);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch pixels" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/config", async (req, res) => {
|
|
try {
|
|
const config = await storage.getCanvasConfig();
|
|
res.json(config);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch config" });
|
|
}
|
|
});
|
|
|
|
// Canvas-Erweiterungs-Endpoint
|
|
app.post("/api/config/expand", async (req, res) => {
|
|
try {
|
|
const { canvasWidth, canvasHeight } = req.body;
|
|
|
|
if (!canvasWidth || !canvasHeight || canvasWidth < 1 || canvasHeight < 1) {
|
|
return res.status(400).json({ message: "Invalid canvas dimensions" });
|
|
}
|
|
|
|
const currentConfig = await storage.getCanvasConfig();
|
|
|
|
// Erlaube nur Erweiterung, nicht Verkleinerung
|
|
if (canvasWidth < currentConfig.canvasWidth || canvasHeight < currentConfig.canvasHeight) {
|
|
return res.status(400).json({
|
|
message: "Canvas kann nur erweitert werden, nicht verkleinert",
|
|
current: { width: currentConfig.canvasWidth, height: currentConfig.canvasHeight }
|
|
});
|
|
}
|
|
|
|
// Für SQLite Storage: Verwende spezielle expandCanvas Methode
|
|
if ('expandCanvas' in storage && typeof storage.expandCanvas === 'function') {
|
|
await (storage as any).expandCanvas(canvasWidth, canvasHeight);
|
|
} else {
|
|
// Fallback für Memory Storage
|
|
await storage.updateCanvasConfig({
|
|
canvasWidth,
|
|
canvasHeight,
|
|
defaultCooldown: currentConfig.defaultCooldown,
|
|
enableAutomaticEvents: currentConfig.enableAutomaticEvents,
|
|
eventDuration: currentConfig.eventDuration,
|
|
eventInterval: currentConfig.eventInterval
|
|
});
|
|
}
|
|
|
|
const updatedConfig = await storage.getCanvasConfig();
|
|
res.json(updatedConfig);
|
|
} catch (error) {
|
|
console.error("Failed to expand canvas:", error);
|
|
res.status(500).json({ message: "Failed to expand canvas" });
|
|
}
|
|
});
|
|
|
|
// Config is now read-only from file
|
|
// Remove the POST endpoint for config updates
|
|
|
|
app.post("/api/pixels", requireAuth, async (req, res) => {
|
|
try {
|
|
const userInfo = getUserFromToken(req);
|
|
const pixelData = insertPixelSchema.parse({
|
|
...req.body,
|
|
userId: userInfo.userId,
|
|
username: userInfo.username
|
|
});
|
|
const config = await storage.getCanvasConfig();
|
|
|
|
// Validate coordinates
|
|
if (pixelData.x < 0 || pixelData.x >= config.canvasWidth ||
|
|
pixelData.y < 0 || pixelData.y >= config.canvasHeight) {
|
|
return res.status(400).json({ message: "Pixel coordinates out of bounds" });
|
|
}
|
|
|
|
// Check cooldown unless events are enabled
|
|
if (!config.enableAutomaticEvents) {
|
|
const cooldown = await storage.getUserCooldown(userInfo.userId);
|
|
const now = new Date();
|
|
|
|
if (cooldown && cooldown.cooldownEnds > now) {
|
|
const remaining = Math.ceil((cooldown.cooldownEnds.getTime() - now.getTime()) / 1000);
|
|
return res.status(429).json({
|
|
message: "Cooldown active",
|
|
remainingSeconds: remaining
|
|
});
|
|
}
|
|
|
|
// Set new cooldown - immer setzen, auch wenn kein vorheriger existierte
|
|
const cooldownEnd = new Date(Date.now() + (config.defaultCooldown * 1000));
|
|
await storage.setUserCooldown({
|
|
userId: userInfo.userId,
|
|
cooldownEnds: cooldownEnd,
|
|
});
|
|
}
|
|
|
|
const pixel = await storage.placePixel(pixelData);
|
|
|
|
// Broadcast pixel placement to all connected clients
|
|
broadcast({
|
|
type: "pixel_placed",
|
|
data: {
|
|
x: pixel.x,
|
|
y: pixel.y,
|
|
color: pixel.color,
|
|
userId: pixel.userId,
|
|
username: pixel.username,
|
|
timestamp: pixel.createdAt.toISOString(),
|
|
},
|
|
});
|
|
|
|
res.json(pixel);
|
|
} catch (error) {
|
|
res.status(400).json({ message: "Invalid pixel data" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/recent", async (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit as string) || 10;
|
|
const recent = await storage.getRecentPlacements(limit);
|
|
res.json(recent);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch recent placements" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/cooldown/:userId", async (req, res) => {
|
|
try {
|
|
const { userId } = req.params;
|
|
const cooldown = await storage.getUserCooldown(userId);
|
|
const config = await storage.getCanvasConfig();
|
|
|
|
if (!cooldown || config.enableAutomaticEvents) {
|
|
return res.json({ remainingSeconds: 0 });
|
|
}
|
|
|
|
const remaining = Math.max(0, Math.ceil((cooldown.cooldownEnds.getTime() - Date.now()) / 1000));
|
|
res.json({ remainingSeconds: remaining });
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch cooldown" });
|
|
}
|
|
});
|
|
|
|
// WebSocket handling
|
|
function broadcast(message: WSMessage) {
|
|
const messageStr = JSON.stringify(message);
|
|
connectedUsers.forEach(ws => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(messageStr);
|
|
}
|
|
});
|
|
}
|
|
|
|
function broadcastUserCount() {
|
|
broadcast({
|
|
type: "user_count",
|
|
data: { count: connectedUsers.size },
|
|
});
|
|
}
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
console.log(`New WebSocket connection from ${req.socket.remoteAddress}`);
|
|
connectedUsers.add(ws);
|
|
broadcastUserCount();
|
|
|
|
// Send current user count immediately
|
|
ws.send(JSON.stringify({
|
|
type: "user_count",
|
|
data: { count: connectedUsers.size },
|
|
}));
|
|
|
|
ws.on('close', (code, reason) => {
|
|
console.log(`WebSocket disconnected: ${code} ${reason}`);
|
|
connectedUsers.delete(ws);
|
|
broadcastUserCount();
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error('WebSocket error:', error);
|
|
connectedUsers.delete(ws);
|
|
broadcastUserCount();
|
|
});
|
|
|
|
ws.on('pong', () => {
|
|
// Keep connection alive
|
|
});
|
|
});
|
|
|
|
// 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 => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.ping();
|
|
}
|
|
});
|
|
}, 30000);
|
|
|
|
return httpServer;
|
|
}
|