Files
jmespath-playground/server.js

460 lines
14 KiB
JavaScript

const express = require("express");
const path = require("path");
const crypto = require("crypto");
const os = require("os");
const { v4: uuidv4 } = require("uuid");
const { parseArgs } = require("util");
// Environment configuration
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS) || 100;
const MAX_SAMPLE_SIZE = parseInt(process.env.MAX_SAMPLE_SIZE) || 1024 * 1024; // 1MB
const MAX_SESSION_TTL = parseInt(process.env.MAX_SESSION_TTL) || 60 * 60 * 1000; // 1 hour
// Utility functions for encryption
function encrypt(data, key) {
try {
const algorithm = "aes-256-gcm";
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
cipher.setAAD(Buffer.from("session-data"));
let encrypted = cipher.update(JSON.stringify(data), "utf8");
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
return {
iv: iv.toString("hex"),
data: encrypted.toString("hex"),
tag: authTag.toString("hex"),
};
} catch (error) {
console.error("⚠️ Encryption exception:", {
message: error.message,
algorithm: "aes-256-gcm",
keyLength: key ? key.length : "undefined",
timestamp: new Date().toISOString(),
});
throw new Error(`Encryption failed: ${error.message}`);
}
}
function decrypt(encryptedObj, key) {
try {
const algorithm = "aes-256-gcm";
const iv = Buffer.from(encryptedObj.iv, "hex");
const decipher = crypto.createDecipheriv(algorithm, key, iv);
decipher.setAAD(Buffer.from("session-data"));
decipher.setAuthTag(Buffer.from(encryptedObj.tag, "hex"));
let decrypted = decipher.update(
Buffer.from(encryptedObj.data, "hex"),
null,
"utf8",
);
decrypted += decipher.final("utf8");
return JSON.parse(decrypted);
} catch (error) {
console.error("⚠️ Decryption exception:", {
message: error.message,
algorithm: "aes-256-gcm",
keyLength: key ? key.length : "undefined",
hasIV: !!encryptedObj.iv,
hasTag: !!encryptedObj.tag,
hasData: !!encryptedObj.data,
timestamp: new Date().toISOString(),
});
throw new Error(`Decryption failed: ${error.message}`);
}
}
function isValidApiKey(apiKey) {
return typeof apiKey === "string" && /^[0-9a-f]{32}$/i.test(apiKey);
}
function getSessionId(apiKey) {
return crypto.createHash("sha256").update(apiKey).digest("hex");
}
function generateSalt() {
return crypto.randomBytes(32);
}
function deriveKey(apiKey, salt) {
return crypto.pbkdf2Sync(apiKey, salt, 100000, 32, "sha256");
}
// Create Express app
function createApp(devMode = false) {
const app = express();
// Trust proxy to get real client IP (needed for localhost detection)
app.set("trust proxy", true);
// Middleware
app.use(express.json({ limit: MAX_SAMPLE_SIZE }));
app.use(express.static(path.join(__dirname, "build")));
// Dev mode request logging middleware
if (devMode) {
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`📨 [${timestamp}] ${req.method} ${req.path}`);
if (req.method !== "GET" && Object.keys(req.body).length > 0) {
const bodySize = Buffer.byteLength(JSON.stringify(req.body), "utf8");
console.log(` Request body size: ${(bodySize / 1024).toFixed(2)}KB`);
}
const originalJson = res.json;
res.json = function (data) {
console.log(` ✓ Response: ${res.statusCode}`);
return originalJson.call(this, data);
};
next();
});
}
// Session storage
const sessions = new Map();
// Cleanup expired sessions
function cleanupExpiredSessions() {
const now = Date.now();
for (const [sessionId, session] of sessions.entries()) {
if (now - session.createdAt > MAX_SESSION_TTL) {
sessions.delete(sessionId);
console.log(
`🧹 Cleaned up expired session: ${sessionId.substring(0, 8)}...`,
);
}
}
}
// Run cleanup every 5 minutes
setInterval(cleanupExpiredSessions, 5 * 60 * 1000);
// API endpoints
app.post("/api/v1/upload", (req, res) => {
try {
const apiKey = req.headers["x-api-key"];
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
// Cleanup expired sessions before checking limits
cleanupExpiredSessions();
// Check session limits
if (sessions.size >= MAX_SESSIONS) {
return res.status(429).json({
error: "Maximum number of sessions reached. Please try again later.",
maxSessions: MAX_SESSIONS,
currentSessions: sessions.size,
});
}
const uploadedData = req.body;
// Validate that it's valid JSON
if (!uploadedData || typeof uploadedData !== "object") {
return res.status(400).json({ error: "Invalid JSON data" });
}
// Check data size
const dataSize = Buffer.byteLength(JSON.stringify(uploadedData), "utf8");
if (dataSize > MAX_SAMPLE_SIZE) {
return res.status(413).json({
error: "Sample data too large",
maxSize: MAX_SAMPLE_SIZE,
receivedSize: dataSize,
});
}
const sessionId = getSessionId(apiKey);
const salt = generateSalt();
const key = deriveKey(apiKey, salt);
const stateGuid = uuidv4();
// Encrypt and store session data
const encryptedData = encrypt(uploadedData, key);
sessions.set(sessionId, {
salt: salt.toString("hex"),
encryptedData,
state: stateGuid,
createdAt: Date.now(),
accessed: false,
});
console.log(
`📁 Session created: ${sessionId.substring(0, 8)}... (${sessions.size}/${MAX_SESSIONS})`,
);
res.json({ message: "OK" });
} catch (error) {
console.error("⚠️ Upload endpoint exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString(),
});
// Provide more specific error messages based on error type
if (error.name === "SyntaxError") {
return res.status(400).json({
error: "Invalid JSON data format",
details: "The uploaded data could not be parsed as valid JSON",
});
} else if (error.message.includes("encrypt")) {
return res.status(500).json({
error: "Encryption failed",
details:
"Failed to encrypt session data. Please try again with a new API key.",
});
} else if (error.message.includes("PBKDF2")) {
return res.status(500).json({
error: "Key derivation failed",
details: "Failed to derive encryption key from API key",
});
} else {
return res.status(500).json({
error: "Upload processing failed",
details:
"An unexpected error occurred while processing your upload. Please try again.",
});
}
}
});
app.get("/api/v1/sample", (req, res) => {
try {
const apiKey = req.headers["x-api-key"];
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
const sessionId = getSessionId(apiKey);
const session = sessions.get(sessionId);
if (!session) {
return res.json(null);
}
// Decrypt data
const salt = Buffer.from(session.salt, "hex");
const key = deriveKey(apiKey, salt);
const decryptedData = decrypt(session.encryptedData, key);
// Remove session after first access (one-time use)
sessions.delete(sessionId);
console.log(
`📤 Sample data retrieved and session cleared: ${sessionId.substring(0, 8)}...`,
);
res.json(decryptedData);
} catch (error) {
console.error("⚠️ Sample retrieval exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString(),
});
// Provide more specific error messages based on error type
if (error.message.includes("decrypt")) {
return res.status(500).json({
error: "Decryption failed",
details:
"Failed to decrypt session data. The session may be corrupted or the API key may be incorrect.",
});
} else if (error.message.includes("JSON")) {
return res.status(500).json({
error: "Data corruption detected",
details:
"The stored session data appears to be corrupted and cannot be parsed.",
});
} else if (error.name === "TypeError") {
return res.status(500).json({
error: "Session data format error",
details: "The session data format is invalid or corrupted.",
});
} else {
return res.status(500).json({
error: "Sample retrieval failed",
details:
"An unexpected error occurred while retrieving sample data. The session may have been corrupted.",
});
}
}
});
app.get("/api/v1/state", (req, res) => {
try {
const apiKey = req.headers["x-api-key"];
// Validate API key header
if (!apiKey || !isValidApiKey(apiKey)) {
return res
.status(403)
.json({ error: "Invalid or missing X-API-Key header" });
}
const sessionId = getSessionId(apiKey);
const session = sessions.get(sessionId);
if (!session) {
// Return null state when no session exists
return res.json({ state: null });
}
res.json({ state: session.state });
} catch (error) {
console.error("⚠️ State retrieval exception occurred:", {
message: error.message,
stack: error.stack,
sessionCount: sessions.size,
timestamp: new Date().toISOString(),
});
// Provide more specific error messages
if (error.message.includes("API key")) {
return res.status(403).json({
error: "API key processing failed",
details: "Failed to process the provided API key",
});
} else {
return res.status(500).json({
error: "State retrieval failed",
details:
"An unexpected error occurred while retrieving session state. Please try again.",
});
}
}
});
// Status endpoint (no auth required) - detailed information
app.get("/api/v1/status", (req, res) => {
cleanupExpiredSessions(); // Cleanup on status check
res.json({
status: "healthy",
sessions: {
current: sessions.size,
max: MAX_SESSIONS,
available: MAX_SESSIONS - sessions.size,
},
limits: {
maxSessions: MAX_SESSIONS,
maxSampleSize: MAX_SAMPLE_SIZE,
maxSessionTTL: MAX_SESSION_TTL,
},
uptime: process.uptime(),
});
});
// Health endpoint (no auth required) - simple OK response
app.get("/api/v1/health", (req, res) => {
res.type("text/plain").send("OK");
});
// Serve React app for all other routes
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "build", "index.html"));
});
return app;
}
// Start server if this file is run directly
if (require.main === module) {
const { values } = parseArgs({
options: {
"listen-addr": {
type: "string",
short: "h",
default: process.env.LISTEN_ADDR || "127.0.0.1",
},
port: {
type: "string",
short: "p",
default: process.env.LISTEN_PORT || "3000",
},
dev: {
type: "boolean",
default: process.env.DEV_MODE === "true" || false,
},
},
});
const DEV_MODE = values.dev;
const app = createApp(DEV_MODE);
const PORT = parseInt(values.port);
const HOST = values["listen-addr"];
app.listen(PORT, HOST, () => {
console.log(`JMESPath Playground Server running`);
if (DEV_MODE) {
console.log(` 🔧 Development Mode Enabled`);
}
// Show actual accessible URLs
if (HOST === "0.0.0.0") {
console.log(` Listening on all interfaces:`);
const interfaces = os.networkInterfaces();
for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs) {
if (addr.family === "IPv4" && !addr.internal) {
console.log(` http://${addr.address}:${PORT}`);
}
}
}
// Also show localhost for local access
console.log(` http://127.0.0.1:${PORT}`);
} else {
console.log(` http://${HOST}:${PORT}`);
}
console.log(`Configuration:`);
console.log(` Max Sessions: ${MAX_SESSIONS}`);
console.log(
` Max Sample Size: ${(MAX_SAMPLE_SIZE / 1024 / 1024).toFixed(1)}MB`,
);
console.log(
` Session TTL: ${(MAX_SESSION_TTL / 1000 / 60).toFixed(0)} minutes`,
);
console.log(
" Security: AES-256-GCM encryption with PBKDF2 (100k iterations)",
);
// Show base API URL
let apiBaseUrl;
if (HOST === "0.0.0.0") {
const interfaces = os.networkInterfaces();
let firstIP = "127.0.0.1";
outer: for (const addrs of Object.values(interfaces)) {
for (const addr of addrs) {
if (addr.family === "IPv4" && !addr.internal) {
firstIP = addr.address;
break outer;
}
}
}
apiBaseUrl = `http://${firstIP}:${PORT}/api/v1`;
} else {
apiBaseUrl = `http://${HOST}:${PORT}/api/v1`;
}
console.log(`API Base URL: ${apiBaseUrl}`);
});
}
module.exports = { createApp };