import { readdir } from "fs/promises"; import express from "express"; import cors from "cors"; import helmet from "helmet"; import path from "path"; import fs from "fs/promises"; import { createServer } from "http"; import { Server } from "socket.io"; import morgan from "morgan"; import { STATIC_DIR, TOPICS } from "./constants.js"; import { getStructure, cache } from "./helper.js"; if (typeof process.env.API_TOKEN === "undefined") { throw new Error("Service cannot be started without API_TOKEN"); } // Load cache on start getStructure({ refresh: true }).catch(console.error); const app = express(); const server = createServer(app); // Socket.IO setup with CORS const io = new Server(server, { cors: { origin: "http://localhost:5173", // React app URL methods: ["GET", "POST"], }, }); const corsOptions = { methods: ["GET", "POST", "PUT", "DELETE"], credentials: true, origin: (origin, callback) => { if (!origin) { if (process.env.NODE_ENV === "production") { return callback(new Error("Origin required in production")); } return callback(null, true); } if (isOriginAllowed(origin)) { return callback(null, true); } else { return callback(new Error("Not allowed by CORS")); } }, }; app.use(cors(corsOptions)); app.use(helmet()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(morgan("tiny")); // Global let data = null; let selectedOptionId = null; // Socket connection handling io.on("connection", (socket) => { console.log("Client connected:", socket.id); socket.emit("dataChanged", data); socket.emit("selectedOptionChanged", selectedOptionId); // Handle array updates from client socket.on("setData", (newData) => { data = newData; console.log("Data changed"); io.emit("dataChanged", data); }); socket.on("setSelectedOptionId", (newId) => { selectedOptionId = newId; console.log("Selected option changed to ", newId); io.emit("selectedOptionChanged", newId); }); socket.on("disconnect", () => { console.log("Client disconnected:", socket.id); }); }); app.get("/_health", (req, res) => { res.json({ healthy: true }); }); app.get( "/resources-status", asyncHandler((req, res) => { res.json({ array: booleanArray }); }), ); // Serve static files with automatic ETag handling app.use( "/content", express.static(STATIC_DIR, { etag: true, // Enable automatic ETag generation lastModified: true, // Include Last-Modified header maxAge: 3600000, // Cache for 1 hour, but always revalidate immutable: false, // Files can change }), ); app.get( "/structure", asyncHandler(async (req, res) => { let { refresh } = req.query; refresh = Boolean(refresh); const structure = await getStructure({ refresh }); res.json(structure); }), ); app.post( "/resources", verifyToken, asyncHandler(async (req, res) => { try { const { topicSeq, resourceSeq, content = "" } = req.body; if (!topicSeq || !resourceSeq) { res.status(400).json({ message: "Missing body" }); return; } if ( typeof topicSeq !== "number" || typeof resourceSeq !== "number" || typeof content !== "string" ) { res.status(400).json({ message: "Invalid body" }); } const topicIdx = topicSeq - 1; const resourceIdx = resourceSeq - 1; const topics = await getStructure(); const resource = topics[topicIdx].resources[resourceIdx]; if (!resource) { res.status(404).json({ message: "Resource not found to update" }); } const filename = `F_${resource.id}_${Date.now()}.md`; const filePath = path.join(STATIC_DIR, filename); await fs.writeFile(filePath, content, "utf8"); resource.filename = filename; res.json({ success: true, filename, structure: topics }); } catch (error) { console.log(error); res.status(500).json({ error: "Failed to update topic resource" }); } }), ); app.post( "/resources/duplicate", verifyToken, asyncHandler(async (req, res) => { try { const { sourceResourceSeq, targetResourceSeq } = req.body; if (!sourceResourceSeq || !targetResourceSeq) { res .status(400) .json({ message: "Missing sourceResourceSeq or targetResourceSeq" }); return; } if ( typeof sourceResourceSeq !== "number" || typeof targetResourceSeq !== "number" ) { res .status(400) .json({ message: "Invalid body - sequences must be numbers" }); return; } if (sourceResourceSeq === targetResourceSeq) { res.status(400).json({ message: "Source and target resource sequences cannot be the same", }); return; } const sourceResourceIdx = sourceResourceSeq - 1; const targetResourceIdx = targetResourceSeq - 1; const topics = await getStructure(); // Validate resource sequences exist (check against first topic to get max resources) if ( sourceResourceIdx < 0 || targetResourceIdx < 0 || sourceResourceIdx >= topics[0].resources.length || targetResourceIdx >= topics[0].resources.length ) { res .status(400) .json({ message: "Invalid resource sequence - out of bounds" }); return; } let duplicatedCount = 0; let skippedCount = 0; const timestamp = Date.now(); // Process all topics with Promise.all to handle async operations properly await Promise.all( topics.map(async (topic) => { const sourceResource = topic.resources[sourceResourceIdx]; const targetResource = topic.resources[targetResourceIdx]; // Skip if source resource has no content if (!sourceResource || !sourceResource.filename) { skippedCount++; return; } try { // Read content from source file const sourceFilePath = path.join( STATIC_DIR, sourceResource.filename, ); const content = await fs.readFile(sourceFilePath, "utf8"); // Create new filename for target resource const newFilename = `F_${targetResource.id}_${timestamp}.md`; const targetFilePath = path.join(STATIC_DIR, newFilename); // Write content to new file await fs.writeFile(targetFilePath, content, "utf8"); // Update target resource filename targetResource.filename = newFilename; duplicatedCount++; } catch (fileError) { console.error( `Failed to duplicate resource for topic ${topic.id}:`, fileError, ); skippedCount++; } }), ); res.json({ success: true, duplicatedCount, skippedCount, message: `Successfully duplicated ${duplicatedCount} resources from sequence ${sourceResourceSeq} to ${targetResourceSeq}. Skipped ${skippedCount} resources.`, structure: topics, }); } catch (error) { console.error("Duplicate resources error:", error); res.status(500).json({ error: "Failed to duplicate resources" }); } }), ); app.use(errorRequestHandler); const PORT = process.env.PORT || 3000; server.listen(PORT, () => console.log("Resource Provider started")); /** * HELPERS */ function asyncHandler(fn) { return (req, res, next) => { return Promise.resolve(fn(req, res, next)).catch(next); }; } function errorRequestHandler(error, _req, res, next) { if (error) { console.log(error); res .status(error.status || 500) .json({ message: error.message || "Server failed" }); } else { next(); } } function isOriginAllowed(origin) { const url = new URL(origin); if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { return true; } return url.hostname.endsWith("tomastm.com"); } function verifyToken(req, res, next) { const token = req.headers["token"]; if (!token || token !== process.env.API_TOKEN) { res.status(401).json({ message: "Token not provided" }); return; } next(); }